diff --git a/views/build.gradle b/views/build.gradle index e76deb5..3ede544 100644 --- a/views/build.gradle +++ b/views/build.gradle @@ -2,6 +2,10 @@ apply from: "../android-configs/lib-config.gradle" apply plugin: 'kotlin-android' android { + defaultConfig { + minSdkVersion 23 + } + buildFeatures { viewBinding true } @@ -14,6 +18,8 @@ dependencies { implementation "com.google.android.material:material" implementation "androidx.core:core-ktx" + implementation "com.redmadrobot:inputmask:3.4.4" + implementation "net.danlew:android.joda:2.10.3" constraints { implementation("com.google.android.material:material") { diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/Alias.kt b/views/src/main/java/ru/touchin/roboswag/views/input/Alias.kt new file mode 100644 index 0000000..5738ace --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/Alias.kt @@ -0,0 +1,5 @@ +package ru.touchin.roboswag.views.input + +import android.view.MotionEvent + +typealias TouchListener = (event: MotionEvent) -> Unit diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/BaseListenersContainer.kt b/views/src/main/java/ru/touchin/roboswag/views/input/BaseListenersContainer.kt new file mode 100644 index 0000000..69de88f --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/BaseListenersContainer.kt @@ -0,0 +1,9 @@ +package ru.touchin.roboswag.views.input + +open class BaseListenersContainer(protected val listeners: MutableList = mutableListOf()) { + + fun add(listener: E): Boolean = listeners.add(listener) + + fun remove(listener: E): Boolean = listeners.remove(listener) + +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/BaseTextInputView.kt b/views/src/main/java/ru/touchin/roboswag/views/input/BaseTextInputView.kt new file mode 100644 index 0000000..97e842e --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/BaseTextInputView.kt @@ -0,0 +1,257 @@ +package ru.touchin.roboswag.views.input + +import android.content.Context +import android.content.res.TypedArray +import android.graphics.Color +import android.os.Parcelable +import android.text.InputType +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.inputmethod.EditorInfo +import android.widget.LinearLayout +import androidx.annotation.ColorRes +import androidx.annotation.StringRes +import androidx.core.content.withStyledAttributes +import ru.touchin.roboswag.views.R +import ru.touchin.roboswag.views.input.delegates.InputEventDelegate +import ru.touchin.roboswag.views.input.delegates.bottom_hint.BottomHintState +import ru.touchin.roboswag.views.input.delegates.bottom_hint.InputSupportHintDelegate +import ru.touchin.roboswag.views.input.delegates.bottom_hint.ShowSupportHintMode +import ru.touchin.roboswag.views.input.delegates.edit_text.InputEditorDelegate +import ru.touchin.roboswag.views.input.delegates.edit_text.InputEditorDelegateImpl +import ru.touchin.roboswag.views.input.delegates.validators.InputValidatorDelegate +import ru.touchin.roboswag.views.input.delegates.validators.InputValidatorDelegateImpl +import ru.touchin.roboswag.views.input.delegates.validators.ValidatorTypes +import ru.touchin.roboswag.views.input.utils.MaskConstants +import ru.touchin.roboswag.views.input.validatable.validator.Validator +import ru.touchin.roboswag.views.input.validatable.validator.implementation.CyrillicValidator +import ru.touchin.roboswag.views.input.validatable.validator.implementation.DateFormatValidator +import ru.touchin.roboswag.views.input.validatable.validator.implementation.EmailValidator +import ru.touchin.roboswag.views.input.validatable.validator.implementation.EmptyValidator +import ru.touchin.roboswag.views.input.validatable.validator.implementation.LatinValidator +import ru.touchin.roboswag.views.input.validatable.validator.implementation.NumberValidator +import ru.touchin.roboswag.views.input.validatable.validator.implementation.PhoneValidator +import ru.touchin.roboswag.views.input.validatable.validator.implementation.TimeValidator + +@Suppress("TooManyFunctions") +open class BaseTextInputView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr), InputValidatorDelegate by InputValidatorDelegateImpl(), + InputEditorDelegate by InputEditorDelegateImpl() { + + companion object { + private const val NO_RESOURCE = -1 + + private const val MAX_LENGTH_FOR_SINGLE_LINE_EDIT_TEXT = 5000 + private const val ONE_LINE_EDIT_TEXT = 1 + private const val NONE = 0 + } + + private val eventDelegate = InputEventDelegate() + + init { + LayoutInflater.from(context).inflate(R.layout.view_base_input_text, this, true) + initDelegates() + + context.withStyledAttributes(attrs, R.styleable.BaseTextInputView, defStyleAttr, 0) { + setupMask() + setupEditOptions() + setupEditTextPadding() + setupHint() + setupSupportHint() + val errorMessageId = setupError() + setupText() + + setMaskFromAttr(getInt(R.styleable.BaseTextInputView_mask, MaskTypes.NONE.ordinal)) + + setValidator( + createValidatorFromAttr( + index = getInt(R.styleable.BaseTextInputView_validator, NONE), + errorId = errorMessageId + ) + ) + setValidatorMode(getInt(R.styleable.BaseTextInputView_validatorMode, InputValidatorDelegate.FLAG_NONE)) + } + } + + private fun TypedArray.setupText() { + setText(getString(R.styleable.BaseTextInputView_editText)) + setTextColor(getColor(R.styleable.BaseTextInputView_editTextColor, Color.BLACK)) + val appearance = getResourceId(R.styleable.BaseTextInputView_editTextAppearance, NO_RESOURCE) + setTextAppearance(if (appearance == NO_RESOURCE) null else appearance) + setEditTextBackgroundTint(getColor(R.styleable.BaseTextInputView_editTextBackgroundTint, getCurrentHintTextColor())) + } + + private fun TypedArray.setupError(): Int { + setSupportHintEnable(getBoolean(R.styleable.BaseTextInputView_errorEnabled, false)) + val errorMessageId = getResourceId(R.styleable.BaseTextInputView_error, NO_RESOURCE) + + showErrorHintText(if (errorMessageId == NO_RESOURCE) null else context.getString(errorMessageId)) + setupErrorColor(getColor(R.styleable.BaseTextInputView_errorTextColor, InputSupportHintDelegate.DEFAULT_ERROR_COLOR)) + setSupportHintAppearance(getResourceId(R.styleable.BaseTextInputView_errorTextAppearance, NO_RESOURCE)) + isFloating(getBoolean(R.styleable.BaseTextInputView_floatError, true)) + + return errorMessageId + } + + private fun TypedArray.setupSupportHint() { + setInformationHintText(getString(R.styleable.BaseTextInputView_informationHint)) + val index = getInt(R.styleable.BaseTextInputView_showInformationHintMode, ShowSupportHintMode.PERMANENT.ordinal) + val informationHintColor = getColor(R.styleable.BaseTextInputView_informationHintTextColor, InputSupportHintDelegate.DEFAULT_HINT_COLOR) + setupInformationColor(informationHintColor) + val mode = ShowSupportHintMode.values()[index] + setSupportHintMode(mode) + } + + // TODO Сделайть ренейминг на label + private fun TypedArray.setupHint() { + setHint(getString(R.styleable.BaseTextInputView_hint)) + val colorHint = getColor(R.styleable.BaseTextInputView_hintTextColor, InputSupportHintDelegate.DEFAULT_HINT_COLOR) + setHintColor(colorHint) + val appearance = getResourceId(R.styleable.BaseTextInputView_hintTextAppearance, NO_RESOURCE) + setHintAppearance(if (appearance == NO_RESOURCE) null else appearance) + } + + private fun TypedArray.setupEditTextPadding() { + val editTextPadding = getDimensionPixelSize(R.styleable.BaseTextInputView_editTextPadding, NO_RESOURCE) + val editTextPaddingStart = getDimensionPixelSize(R.styleable.BaseTextInputView_editTextPaddingStart, getEditTextPaddingStart()) + val editTextPaddingEnd = getDimensionPixelSize(R.styleable.BaseTextInputView_editTextPaddingEnd, getEditTextPaddingEnd()) + val editTextPaddingTop = getDimensionPixelSize(R.styleable.BaseTextInputView_editTextPaddingTop, getEditTextPaddingTop()) + val editTextPaddingBottom = getDimensionPixelSize(R.styleable.BaseTextInputView_editTextPaddingBottom, getEditTextPaddingBottom()) + + if (editTextPadding == NO_RESOURCE) { + setEditTextPadding( + start = editTextPaddingStart, + end = editTextPaddingEnd, + top = editTextPaddingTop, + bottom = editTextPaddingBottom + ) + } else { + setEditTextPadding(editTextPadding) + } + } + + private fun TypedArray.setupEditOptions() { + setImeOptions(getInt(R.styleable.BaseTextInputView_imeOptions, EditorInfo.IME_NULL)) + setInputType(getInt(R.styleable.BaseTextInputView_inputType, InputType.TYPE_CLASS_TEXT)) + setDigits(getString(R.styleable.BaseTextInputView_digits).orEmpty()) + setDrawableEnd(getDrawable(R.styleable.BaseTextInputView_drawableEnd)) + setMaxLength(getInt(R.styleable.BaseTextInputView_maxLength, MAX_LENGTH_FOR_SINGLE_LINE_EDIT_TEXT)) + setMaxLines(getInt(R.styleable.BaseTextInputView_maxLines, ONE_LINE_EDIT_TEXT)) + + isFocusable = getBoolean(R.styleable.BaseTextInputView_editTextFocusable, false) + isFocusableInTouchMode = getBoolean(R.styleable.BaseTextInputView_editTextFocusableInTouchMode, true) + isLongClickable = getBoolean(R.styleable.BaseTextInputView_editTextLongClickable, true) + } + + private fun TypedArray.setupMask() { + setMaskedHint(getString(R.styleable.BaseTextInputView_editTextMaskedHint).orEmpty()) + setMaskedHintColor(getColor(R.styleable.BaseTextInputView_editTextMaskedHintColor, Color.BLACK)) + } + + private fun createValidatorFromAttr(index: Int, @StringRes errorId: Int): Validator? { + val validatorType = ValidatorTypes.values()[index] + + return when (validatorType) { + ValidatorTypes.NONE -> null + ValidatorTypes.CYRILLIC -> CyrillicValidator(errorId) + ValidatorTypes.PHONE -> PhoneValidator(errorId) + ValidatorTypes.DATE -> DateFormatValidator(errorId) + ValidatorTypes.TIME -> TimeValidator(errorId) + ValidatorTypes.EMPTY -> EmptyValidator(errorId) + ValidatorTypes.EMAIL -> EmailValidator(errorId) + ValidatorTypes.LATIN -> LatinValidator(errorId) + ValidatorTypes.NUMBER -> NumberValidator(errorId) + } + } + + private fun setMaskFromAttr(index: Int) { + val maskType = MaskTypes.values()[index] + + val format = when (maskType) { + MaskTypes.NONE -> null + MaskTypes.PHONE -> MaskConstants.PHONE_MASK + MaskTypes.DATE -> MaskConstants.DATE_MASK + MaskTypes.BANK_CARD -> MaskConstants.BANK_CARD + MaskTypes.MID -> MaskConstants.MID + } + + when (format != null) { + true -> setupMask(format, false) + false -> setupMask(null) + } + } + + private fun initDelegates() { + bindWithBaseTextInputView(this) + bindWithEditorDelegate(this) + eventDelegate.initEvents(validatorDelegate = this, editorDelegate = this) + } + + @Suppress("detekt.UnnecessaryApply") + override fun onSaveInstanceState(): Parcelable? = super.onSaveInstanceState()?.let { superState -> + BaseTextInputViewSavedState(superState).apply { + saveData( + BaseTextInputViewSavedState.Data( + text = getExtractedText(), + supportHintText = getSupportHintText(), + bottomHintState = getSupportHintState() + ) + ) + } + } + + override fun onRestoreInstanceState(state: Parcelable?) { + if (state is BaseTextInputViewSavedState) { + super.onRestoreInstanceState(state.superState) + + val restoreData = state.restoreData() + setText(text = restoreData.text, withValidate = false) + + when (restoreData.bottomHintState) { + BottomHintState.INFORMATION -> showInformationHint(restoreData.supportHintText) + BottomHintState.ERROR -> showErrorHintText(restoreData.supportHintText) + else -> Unit + } + + } else { + super.onRestoreInstanceState(state) + } + } + + @Suppress("detekt.LabeledExpression", "ClickableViewAccessibility") + fun onSimpleTouchActionUp(action: () -> Unit) = onTouchActionUp { + action.invoke() + + return@onTouchActionUp true + } + + @Suppress("detekt.LabeledExpression", "ClickableViewAccessibility") + fun onTouchActionUp(action: (MotionEvent) -> Boolean) { + setOnTouchListener { _, event -> + return@setOnTouchListener when (event.action) { + MotionEvent.ACTION_UP -> action.invoke(event) + else -> false + } + } + } + + fun setText(text: String?, withValidate: Boolean) = when (withValidate) { + true -> setText(text).also { manualValidate() } + false -> actionWithDisableValidate { setText(text) } + } + + fun setTextColorId(@ColorRes colorId: Int) = setTextColor(context.getColor(colorId)) + + private enum class MaskTypes { + NONE, + PHONE, + DATE, + BANK_CARD, + MID + } + +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/BaseTextInputViewSavedState.kt b/views/src/main/java/ru/touchin/roboswag/views/input/BaseTextInputViewSavedState.kt new file mode 100644 index 0000000..8c70056 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/BaseTextInputViewSavedState.kt @@ -0,0 +1,55 @@ +package ru.touchin.roboswag.views.input + +import android.os.Parcel +import android.os.Parcelable +import android.view.View +import ru.touchin.roboswag.views.input.delegates.bottom_hint.BottomHintState + +class BaseTextInputViewSavedState : View.BaseSavedState { + + private var savedState = Data() + + fun saveData(newState: Data) { + savedState = newState + } + + fun restoreData(): Data = savedState + + constructor(parcel: Parcel) : super(parcel) { + savedState = savedState.copy( + text = parcel.readString().orEmpty(), + supportHintText = parcel.readString(), + bottomHintState = getSupportHintState(parcel) + ) + } + + private fun getSupportHintState(parcel: Parcel) = parcel.readString() + ?.let(BottomHintState::valueOf) + ?: BottomHintState.INFORMATION + + constructor(superState: Parcelable) : super(superState) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + super.writeToParcel(parcel, flags) + parcel.writeString(savedState.text) + parcel.writeString(savedState.supportHintText) + parcel.writeString(savedState.bottomHintState.name) + } + + companion object { + + @JvmField + val CREATOR = object : Parcelable.Creator { + + override fun createFromParcel(parcel: Parcel): BaseTextInputViewSavedState = BaseTextInputViewSavedState(parcel) + + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } + + data class Data( + val text: String = "", + val supportHintText: String? = "", + val bottomHintState: BottomHintState = BottomHintState.INFORMATION + ) +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/FocusChangeListenerContainer.kt b/views/src/main/java/ru/touchin/roboswag/views/input/FocusChangeListenerContainer.kt new file mode 100644 index 0000000..9fa501c --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/FocusChangeListenerContainer.kt @@ -0,0 +1,17 @@ +package ru.touchin.roboswag.views.input + +import android.view.View + +class FocusChangeListenerContainer : BaseListenersContainer(), View.OnFocusChangeListener { + + override fun onFocusChange(v: View?, hasFocus: Boolean) { + for (i in listeners.indices) { + listeners[i].onFocusChange(v, hasFocus) + } + } + + fun add(listener: (Boolean) -> Unit): Boolean = listeners.add( + View.OnFocusChangeListener { _, hasFocus -> listener.invoke(hasFocus) } + ) + +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/GlobalEditTextEventListener.kt b/views/src/main/java/ru/touchin/roboswag/views/input/GlobalEditTextEventListener.kt new file mode 100644 index 0000000..5aa704b --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/GlobalEditTextEventListener.kt @@ -0,0 +1,6 @@ +package ru.touchin.roboswag.views.input + +import android.text.TextWatcher +import android.view.View + +interface GlobalEditTextEventListener : View.OnFocusChangeListener, TextWatcher diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/HintedMaskedEditTextListener.kt b/views/src/main/java/ru/touchin/roboswag/views/input/HintedMaskedEditTextListener.kt new file mode 100644 index 0000000..707d61e --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/HintedMaskedEditTextListener.kt @@ -0,0 +1,86 @@ +package ru.touchin.roboswag.views.input + +import android.text.Editable +import android.text.SpannableString +import android.text.TextWatcher +import android.text.style.ForegroundColorSpan +import android.widget.EditText +import androidx.annotation.ColorInt +import com.redmadrobot.inputmask.MaskedTextChangedListener +import com.redmadrobot.inputmask.helper.Mask +import com.redmadrobot.inputmask.model.CaretString + +class HintedMaskedEditTextListener( + format: String, + field: EditText, + autocomplete: Boolean, + valueListener: ValueListener? = null, + listener: TextWatcher? = null +) : MaskedTextChangedListener(format, field = field, autocomplete = autocomplete, valueListener = valueListener, listener = listener) { + + lateinit var hint: String + + @ColorInt + var hintColor: Int = field.context.getColor(android.R.color.darker_gray) + + override fun afterTextChanged(edit: Editable?) { + field.get()?.removeTextChangedListener(this) + + edit?.replace(0, edit.length, afterText + getCurrentHint()) + + if (hint.isNotEmpty()) { + edit?.setSpan(ForegroundColorSpan(hintColor), afterText.length, hint.length, SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE) + } + + field.get()?.setSelection(this.caretPosition) + + field.get()?.addTextChangedListener(this) + listener?.afterTextChanged(edit) + } + + // Point deletion logic is used to auto removing delimiter and change caret position + // Ex: 12.34.|5678 -> 12.3|5.678y, not 12.34.|5678 -> 12.34|.5678 + override fun onTextChanged(text: CharSequence, cursorPosition: Int, before: Int, count: Int) { + val isDeletion: Boolean = before > 0 && count == 0 + val isPointDeletion: Boolean = isDeletion && getMaskDotsDelimiterIndex().contains(cursorPosition) + val pointDeletionString = if (isPointDeletion) StringBuilder(text).apply { deleteCharAt(cursorPosition - 1) }.toString() else "" + + val result: Mask.Result = + this.mask.apply( + CaretString( + if (isPointDeletion) pointDeletionString else text.toString(), + when { + isPointDeletion -> cursorPosition - 1 + isDeletion -> cursorPosition + else -> cursorPosition + count + } + ), + this.autocomplete && !isDeletion + ) + this.afterText = result.formattedText.string + this.caretPosition = when { + isPointDeletion -> cursorPosition - 1 + isDeletion -> cursorPosition + else -> result.formattedText.caretPosition + } + this.valueListener?.onTextChanged(result.complete, result.extractedValue) + listener?.onTextChanged(result.extractedValue, cursorPosition, before, count) + } + + private fun getCurrentHint(): String = if (hint.isNotEmpty()) { + hint.substring(afterText.length, hint.length) + } else { + "" + } + + private fun getMaskDotsDelimiterIndex(): List { + val indexDelimiterList = mutableListOf() + + hint.forEachIndexed { index, char -> + if (char == '.') indexDelimiterList.add(index) + } + + return indexDelimiterList + } + +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/MaskedHintEditText.kt b/views/src/main/java/ru/touchin/roboswag/views/input/MaskedHintEditText.kt new file mode 100644 index 0000000..dbf067f --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/MaskedHintEditText.kt @@ -0,0 +1,76 @@ +package ru.touchin.roboswag.views.input + +import android.content.Context +import android.graphics.Rect +import android.text.TextWatcher +import android.util.AttributeSet +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.core.content.withStyledAttributes +import com.google.android.material.textfield.TextInputEditText +import ru.touchin.roboswag.views.R + +class MaskedHintEditText @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : TextInputEditText(context, attrs, defStyleAttr) { + + private var maskedEditTextListener: HintedMaskedEditTextListener? = null + + lateinit var maskedHint: String + + @ColorInt + private var maskedHintColor: Int = context.getColor(android.R.color.darker_gray) + + init { + context.withStyledAttributes(attrs, R.styleable.MaskedHintEditText, defStyleAttr, 0) { + maskedHint = getString(R.styleable.MaskedHintEditText_maskedHint).orEmpty() + maskedHintColor = getColor(R.styleable.MaskedHintEditText_maskedHintColor, maskedHintColor) + } + } + + override fun addTextChangedListener(watcher: TextWatcher) { + val textWatcher = watcher as? HintedMaskedEditTextListener + + textWatcher?.let { + removeTextChangedListener(maskedEditTextListener) + maskedEditTextListener = textWatcher + textWatcher.hint = maskedHint + textWatcher.hintColor = maskedHintColor + } + + super.addTextChangedListener(watcher) + } + + override fun onSelectionChanged(selStart: Int, selEnd: Int) { + super.onSelectionChanged(selStart, selEnd) + + val inputStringLength = maskedEditTextListener?.afterText?.length ?: selEnd + + if (selStart > inputStringLength) { + setSelection(inputStringLength) + } + } + + fun getRawText(): String = maskedEditTextListener?.afterText ?: text?.toString().orEmpty() + + override fun onFocusChanged(focused: Boolean, direction: Int, previouslyFocusedRect: Rect?) { + super.onFocusChanged(focused, direction, previouslyFocusedRect) + maskedEditTextListener?.let { + when { + focused && getRawText().isEmpty() -> maskedEditTextListener?.hint = maskedHint + !focused && getRawText().isEmpty() -> maskedEditTextListener?.hint = "" + } + } + } + + fun setMaskedHintColorId(@ColorRes colorId: Int) { + maskedHintColor = context.getColor(colorId) + } + + fun setMaskedHintColor(color: Int) { + maskedHintColor = color + } + +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/TextWatcherContainer.kt b/views/src/main/java/ru/touchin/roboswag/views/input/TextWatcherContainer.kt new file mode 100644 index 0000000..bc8a0dd --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/TextWatcherContainer.kt @@ -0,0 +1,17 @@ +package ru.touchin.roboswag.views.input + +import android.text.Editable +import android.text.TextWatcher + +class TextWatcherContainer : BaseListenersContainer(), TextWatcher { + + override fun beforeTextChanged(text: CharSequence?, start: Int, count: Int, after: Int) = + listeners.forEach { textWatcher -> textWatcher.beforeTextChanged(text, start, count, after) } + + override fun onTextChanged(text: CharSequence?, start: Int, before: Int, count: Int) = + listeners.forEach { textWatcher -> textWatcher.onTextChanged(text, start, before, count) } + + override fun afterTextChanged(text: Editable?) = + listeners.forEach { textWatcher -> textWatcher.afterTextChanged(text) } + +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/TouchEventListenerContainer.kt b/views/src/main/java/ru/touchin/roboswag/views/input/TouchEventListenerContainer.kt new file mode 100644 index 0000000..45dd76a --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/TouchEventListenerContainer.kt @@ -0,0 +1,20 @@ +package ru.touchin.roboswag.views.input + +import android.annotation.SuppressLint +import android.view.MotionEvent +import android.view.View + +class TouchEventListenerContainer : BaseListenersContainer(), View.OnTouchListener { + + private var mainTouchEventListener: View.OnTouchListener? = null + + fun setMainTouchEventListener(listener: View.OnTouchListener?) { + mainTouchEventListener = listener + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouch(v: View?, event: MotionEvent): Boolean = mainTouchEventListener + ?.onTouch(v, event).also { listeners.forEach { it.invoke(event) } } + ?: false + +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/delegates/InputEventDelegate.kt b/views/src/main/java/ru/touchin/roboswag/views/input/delegates/InputEventDelegate.kt new file mode 100644 index 0000000..31d570f --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/delegates/InputEventDelegate.kt @@ -0,0 +1,72 @@ +package ru.touchin.roboswag.views.input.delegates + +import android.text.Editable +import android.text.TextWatcher +import android.view.View +import ru.touchin.roboswag.views.input.GlobalEditTextEventListener +import ru.touchin.roboswag.views.input.delegates.edit_text.InputEditorDelegate +import ru.touchin.roboswag.views.input.delegates.validators.InputValidatorDelegate +import ru.touchin.roboswag.views.input.delegates.validators.InputValidatorDelegate.Companion.FLAG_AFTER_TEXT_CHANGED +import ru.touchin.roboswag.views.input.delegates.validators.InputValidatorDelegate.Companion.FLAG_BEFORE_TEXT_CHANGED +import ru.touchin.roboswag.views.input.delegates.validators.InputValidatorDelegate.Companion.FLAG_HAS_FOCUS +import ru.touchin.roboswag.views.input.delegates.validators.InputValidatorDelegate.Companion.FLAG_LOSS_FOCUS +import ru.touchin.roboswag.views.input.delegates.validators.InputValidatorDelegate.Companion.FLAG_ON_TEXT_CHANGED + +class InputEventDelegate { + + // TODO перекомпановать инпут: InputEventDelegate должен отвечает только за поставку событий, + // "handle" должен быть в другом месте + + private lateinit var globalEditTextEventListener: GlobalEditTextEventListener + + fun initEvents(validatorDelegate: InputValidatorDelegate, editorDelegate: InputEditorDelegate) { + setupGlobalEventListener(validatorDelegate) + bindWithEditDelegateEvents(editorDelegate) + } + + private fun bindWithEditDelegateEvents(editorDelegate: InputEditorDelegate) { + editorDelegate.setOnFocusChangeListener(globalEditTextEventListener) + + editorDelegate.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(text: CharSequence?, start: Int, count: Int, after: Int) = + globalEditTextEventListener.beforeTextChanged(text, start, count, after) + + override fun onTextChanged(text: CharSequence?, start: Int, before: Int, count: Int) = + globalEditTextEventListener.onTextChanged(text, start, before, count) + + override fun afterTextChanged(text: Editable?) = + globalEditTextEventListener.afterTextChanged(text) + }) + } + + private fun setupGlobalEventListener(validatorDelegate: InputValidatorDelegate) { + globalEditTextEventListener = object : GlobalEditTextEventListener { + override fun onFocusChange(v: View?, hasFocus: Boolean) = when (hasFocus) { + true -> handleEditEvent(FLAG_HAS_FOCUS, validatorDelegate) + false -> handleEditEvent(FLAG_LOSS_FOCUS, validatorDelegate) + } + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = + handleEditEvent(FLAG_BEFORE_TEXT_CHANGED, validatorDelegate) + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = + handleEditEvent(FLAG_ON_TEXT_CHANGED, validatorDelegate) + + override fun afterTextChanged(s: Editable?) = + handleEditEvent(FLAG_AFTER_TEXT_CHANGED, validatorDelegate) + } + } + + private fun handleEditEvent(event: Int, validateDelegate: InputValidatorDelegate) { + when (event and validateDelegate.getValidateModeFlag()) { + FLAG_HAS_FOCUS, + FLAG_LOSS_FOCUS, + FLAG_BEFORE_TEXT_CHANGED, + FLAG_ON_TEXT_CHANGED, + FLAG_AFTER_TEXT_CHANGED -> { + validateDelegate.validate() + } + } + } + +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/delegates/bottom_hint/BottomHintState.kt b/views/src/main/java/ru/touchin/roboswag/views/input/delegates/bottom_hint/BottomHintState.kt new file mode 100644 index 0000000..5e177af --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/delegates/bottom_hint/BottomHintState.kt @@ -0,0 +1,7 @@ +package ru.touchin.roboswag.views.input.delegates.bottom_hint + +enum class BottomHintState { + ERROR, + INFORMATION, + EMPTY +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/delegates/bottom_hint/InputSupportHintDelegate.kt b/views/src/main/java/ru/touchin/roboswag/views/input/delegates/bottom_hint/InputSupportHintDelegate.kt new file mode 100644 index 0000000..181ca91 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/delegates/bottom_hint/InputSupportHintDelegate.kt @@ -0,0 +1,51 @@ +package ru.touchin.roboswag.views.input.delegates.bottom_hint + +import android.graphics.Color +import android.graphics.drawable.Drawable +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.annotation.StyleRes + +@Suppress("detekt.TooManyFunctions") +interface InputSupportHintDelegate { + + companion object { + // TODO Заменить на использование цветов из ресурсов + const val DEFAULT_ERROR_COLOR = Color.RED + const val DEFAULT_HINT_COLOR = Color.LTGRAY + } + + fun showSupportHint() + fun hideSupportHint() + fun supportHintIsShow(): Boolean + fun isFloating(isFloating: Boolean) + fun setSupportHintEnable(isEnable: Boolean) + fun setSupportHintMode(mode: ShowSupportHintMode) + fun getSupportHintState(): BottomHintState + fun hasError(): Boolean + + fun setSupportHintText(text: String?) + fun setSupportHintText(text: String?, state: BottomHintState) + fun setSupportHintText(@StringRes textId: Int) + fun setSupportHintTextColor(color: Int) + fun setSupportHintTextColorId(@ColorRes colorId: Int) + fun setSupportHintStartDrawable(@DrawableRes drawableId: Int) + fun setSupportHintStartDrawable(drawable: Drawable?) + fun setSupportHintStartDrawable(drawable: Drawable?, sizePx: Int?, gravityInt: Int?, marginPx: Int?) + fun clearSupportHintStartDrawable() + fun getSupportHintText(): String? + + fun showInformationHint() + fun showInformationHint(text: String?) + fun setInformationHintText(text: String?) + fun setupInformationColor(color: Int) + fun setupInformationColorId(@ColorRes colorId: Int) + + fun showErrorHintText(errorText: String?) + fun showErrorHintText(@StringRes errorText: Int) + fun setupErrorColor(color: Int) + + fun setErrorColorId(@ColorRes colorId: Int) + fun setSupportHintAppearance(@StyleRes style: Int?) +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/delegates/bottom_hint/InputSupportHintDelegateImpl.kt b/views/src/main/java/ru/touchin/roboswag/views/input/delegates/bottom_hint/InputSupportHintDelegateImpl.kt new file mode 100644 index 0000000..7519e45 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/delegates/bottom_hint/InputSupportHintDelegateImpl.kt @@ -0,0 +1,232 @@ +package ru.touchin.roboswag.views.input.delegates.bottom_hint + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.core.view.setMargins +import com.google.android.material.textfield.TextInputLayout +import ru.touchin.roboswag.views.R +import ru.touchin.roboswag.views.input.delegates.edit_text.InputEditorDelegateImpl + +@Suppress("detekt.TooManyFunctions") +class InputSupportHintDelegateImpl : InputSupportHintDelegate { + + private companion object { + const val UNDERLINE_FOCUS_HEIGHT = 2.0F + const val UNDERLINE_DEFAULT_HEIGHT = 1.0F + } + + private lateinit var textInputLayout: TextInputLayout + private lateinit var supportHintTextView: TextView + private lateinit var supportHintStartImage: ImageView + private lateinit var underline: View + private var informationHintColor: Int = InputSupportHintDelegate.DEFAULT_HINT_COLOR + private var errorHintColor: Int = InputSupportHintDelegate.DEFAULT_ERROR_COLOR + private var showSupportHintMode: ShowSupportHintMode = ShowSupportHintMode.PERMANENT + private var informationHintText: String? = null + private var supportHintIsShow = false + private var isFloating = true + + private var currentState: BottomHintState = BottomHintState.INFORMATION + set(value) { + field = value + updateUnderlineColor(value, textInputLayout.editText?.hasFocus() ?: false) + } + + fun init( + textInputView: TextInputLayout, + underline: View, + inputEditorDelegateImpl: InputEditorDelegateImpl, + supportHintTextView: TextView, + supportHintStartImage: ImageView + ) { + this.textInputLayout = textInputView + this.underline = underline + this.supportHintTextView = supportHintTextView + this.supportHintStartImage = supportHintStartImage + + setupUnderline(inputEditorDelegateImpl) + } + + private fun setupUnderline(inputEditorDelegateImpl: InputEditorDelegateImpl) { + inputEditorDelegateImpl.setOnFocusChangeListener(View.OnFocusChangeListener { _, hasFocus -> + when (hasFocus) { + true -> underline.scaleY = UNDERLINE_FOCUS_HEIGHT + false -> underline.scaleY = UNDERLINE_DEFAULT_HEIGHT + } + updateUnderlineColor(currentState, hasFocus) + }) + } + + override fun showSupportHint() { + supportHintTextView.isVisible = true + } + + override fun hideSupportHint() { + supportHintTextView.text = null + currentState = BottomHintState.EMPTY + + if (isFloating) { + supportHintTextView.isVisible = false + } + } + + override fun supportHintIsShow(): Boolean = supportHintIsShow + + override fun isFloating(isFloating: Boolean) { + this.isFloating = isFloating + } + + override fun setSupportHintEnable(isEnable: Boolean) { + supportHintTextView.isVisible = isEnable + supportHintStartImage.isVisible = isEnable && supportHintStartImage.drawable != null + } + + override fun setSupportHintMode(mode: ShowSupportHintMode) { + showSupportHintMode = mode + } + + override fun setSupportHintText(text: String?) { + if (text.isNullOrEmpty()) { + hideSupportHint() + + return + } + + val color = when (currentState) { + BottomHintState.ERROR -> errorHintColor + BottomHintState.INFORMATION -> informationHintColor + BottomHintState.EMPTY -> return + } + + supportHintTextView.setTextColor(ColorStateList.valueOf(color)) + + if (supportHintTextView.isVisible || isFloating) { + + supportHintTextView.isVisible = isFloating + + if (supportHintTextView.text.toString() != text) { + // Чтобы не было мельканий + supportHintTextView.text = text + } + } + } + + override fun setSupportHintText(text: String?, state: BottomHintState) { + currentState = state + updateUnderlineColor(currentState, textInputLayout.editText?.hasFocus() ?: false) + setSupportHintText(text) + } + + override fun setSupportHintTextColor(color: Int) { + val colorList = ColorStateList.valueOf(color) + supportHintTextView.setTextColor(colorList) + supportHintStartImage.imageTintList = colorList + } + + override fun setSupportHintTextColorId(colorId: Int) = + supportHintTextView.setTextColor(ColorStateList.valueOf(context().getColor(colorId))) + + override fun setSupportHintStartDrawable(drawableId: Int) = + setSupportHintStartDrawable(ContextCompat.getDrawable(context(), drawableId)) + + override fun setSupportHintStartDrawable(drawable: Drawable?) = + setSupportHintStartDrawable(drawable, null, null, null) + + override fun setSupportHintStartDrawable(drawable: Drawable?, sizePx: Int?, gravityInt: Int?, marginPx: Int?) { + supportHintStartImage.setImageDrawable(drawable) + supportHintStartImage.isVisible = supportHintTextView.isVisible && drawable != null + + val layoutParams = supportHintStartImage.layoutParams as? LinearLayout.LayoutParams ?: return + + sizePx?.let { size -> + layoutParams.width = size + layoutParams.height = size + } + gravityInt?.let { gravity -> + layoutParams.gravity = gravity + } + marginPx?.let { margin -> + layoutParams.setMargins(margin) + } + + supportHintStartImage.layoutParams = layoutParams + supportHintStartImage.requestLayout() + } + + override fun clearSupportHintStartDrawable() { + supportHintStartImage.setImageDrawable(null) + supportHintStartImage.isVisible = false + } + + private fun context(): Context = textInputLayout.context + + private fun updateUnderlineColor(currentState: BottomHintState, hasFocus: Boolean) { + val color = when { + currentState == BottomHintState.ERROR -> errorHintColor + hasFocus -> context().getColor(android.R.color.darker_gray) + else -> informationHintColor + } + + underline.background = ColorDrawable(color) + } + + override fun getSupportHintState(): BottomHintState = currentState + + override fun hasError(): Boolean = currentState == BottomHintState.ERROR + + override fun setSupportHintText(@StringRes textId: Int) = setSupportHintText(context().getString(textId)) + + override fun getSupportHintText(): String? = supportHintTextView.text?.toString() + + override fun setInformationHintText(text: String?) { + informationHintText = text + } + + override fun setupInformationColor(color: Int) { + informationHintColor = color + } + + override fun setupInformationColorId(colorId: Int) = setupInformationColor(context().getColor(colorId)) + + override fun showInformationHint(text: String?) { + informationHintText = text + showInformationHint() + } + + override fun showInformationHint() = setSupportHintText(informationHintText, BottomHintState.INFORMATION) + + override fun showErrorHintText(errorText: String?) = setSupportHintText(errorText, BottomHintState.ERROR) + + override fun showErrorHintText(errorText: Int) = showErrorHintText(context().getString(errorText)) + + override fun setupErrorColor(color: Int) { + errorHintColor = color + } + + override fun setErrorColorId(colorId: Int) = setupErrorColor(context().getColor(colorId)) + + override fun setSupportHintAppearance(style: Int?) { + if (style == null) return + supportHintTextView.setTextAppearance(style) + } + + fun clearCurrentError() { + val currentText = textInputLayout.editText?.text?.toString().orEmpty() + + when { + informationHintText.isNullOrEmpty() -> hideSupportHint() + showSupportHintMode == ShowSupportHintMode.PERMANENT -> showInformationHint() + showSupportHintMode == ShowSupportHintMode.ON_EMPTY && currentText.isEmpty() -> showInformationHint() + else -> hideSupportHint() + } + } +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/delegates/bottom_hint/ShowSupportHintMode.kt b/views/src/main/java/ru/touchin/roboswag/views/input/delegates/bottom_hint/ShowSupportHintMode.kt new file mode 100644 index 0000000..30f7245 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/delegates/bottom_hint/ShowSupportHintMode.kt @@ -0,0 +1,6 @@ +package ru.touchin.roboswag.views.input.delegates.bottom_hint + +enum class ShowSupportHintMode { + PERMANENT, + ON_EMPTY +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/delegates/edit_text/InputEditorDelegate.kt b/views/src/main/java/ru/touchin/roboswag/views/input/delegates/edit_text/InputEditorDelegate.kt new file mode 100644 index 0000000..836e98d --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/delegates/edit_text/InputEditorDelegate.kt @@ -0,0 +1,94 @@ +package ru.touchin.roboswag.views.input.delegates.edit_text + +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.text.InputFilter +import android.text.TextWatcher +import android.view.MotionEvent +import android.view.View +import android.widget.EditText +import android.widget.TextView +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.StyleRes +import ru.touchin.roboswag.views.input.BaseTextInputView +import ru.touchin.roboswag.views.input.HintedMaskedEditTextListener +import ru.touchin.roboswag.views.input.TouchListener +import ru.touchin.roboswag.views.input.delegates.bottom_hint.InputSupportHintDelegate + +@Suppress("detekt.TooManyFunctions") +interface InputEditorDelegate : InputSupportHintDelegate { + + fun bindWithBaseTextInputView(baseTextInputView: BaseTextInputView) + fun getEditText(): EditText + + fun getExtractedText(): String + fun getRawText(): String + fun setText(text: String?) + fun getSelectionStart(): Int + fun setSelectionStart(index: Int) + fun setSelectorToEnd() + + fun setHint(text: String?) + fun setHintColor(color: Int) + fun setHintColorId(@ColorRes colorId: Int) + fun setHintAppearance(@StyleRes style: Int?) + + fun getCurrentHintTextColor(): Int + + fun setFilters(vararg filters: InputFilter) + fun setLongClickable(longClickable: Boolean) + fun setEnabled(enabled: Boolean) + fun setOnEditorActionListener(listener: TextView.OnEditorActionListener) + fun setOnTouchListener(listener: View.OnTouchListener?) + fun setSupportTouchListener(listener: TouchListener) + + fun isEndDrawableIconClicked(event: MotionEvent): Boolean + fun setFocusable(focusable: Boolean) + fun setFocusableInTouchMode(focusable: Boolean) + fun hasFocus(): Boolean + fun clearFocus() + fun setOnIconClickListener(listener: (() -> Unit)?) + fun requestFocus(direction: Int, previouslyFocusedRect: Rect?): Boolean + fun setOnFocusChangeListener(listener: View.OnFocusChangeListener?) + fun removeOnFocusChangeListener(listener: View.OnFocusChangeListener) + + fun setTextColor(color: Int) + fun setTextAppearance(@StyleRes style: Int?) + + fun setInputType(type: Int) + fun setImeOptions(imeOptions: Int) + + fun setDigits(digit: String) + + fun setMaxLines(maxLines: Int) + fun setMaxLength(maxLength: Int) + fun setDrawableEnd(@DrawableRes drawable: Int) + fun setDrawableEnd(drawable: Drawable?) + + fun setEditTextBackgroundTint(color: Int) + fun setEditTextBackgroundTintId(@ColorRes colorId: Int) + + fun addTextChangedListener(watcher: TextWatcher) + fun removeTextChangedListener(watcher: TextWatcher) + fun setupMask(format: String, autocomplete: Boolean) + fun setupMask(maskListener: HintedMaskedEditTextListener?) + fun setOnMaskFilledListener(listener: MaskFilledListener?) + fun setOnMaskFilledListener(listener: (Boolean, String) -> Unit) + fun setMaskedHint(text: String) + fun setMaskedHintColor(color: Int) + fun setMaskedHintColorId(@ColorRes colorId: Int) + fun maskIsFilled(): Boolean? + + fun setEditTextMargin(margin: Int) + fun setEditTextMargin(start: Int, top: Int, end: Int, bottom: Int) + + fun setEditTextPadding(start: Int, top: Int, end: Int, bottom: Int) + fun setEditTextPadding(padding: Int) + + fun getEditTextPaddingStart(): Int + fun getEditTextPaddingEnd(): Int + fun getEditTextPaddingTop(): Int + fun getEditTextPaddingBottom(): Int + +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/delegates/edit_text/InputEditorDelegateImpl.kt b/views/src/main/java/ru/touchin/roboswag/views/input/delegates/edit_text/InputEditorDelegateImpl.kt new file mode 100644 index 0000000..06d91c5 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/delegates/edit_text/InputEditorDelegateImpl.kt @@ -0,0 +1,394 @@ +package ru.touchin.roboswag.views.input.delegates.edit_text + +import android.annotation.SuppressLint +import android.content.res.ColorStateList +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.text.InputFilter +import android.text.TextWatcher +import android.text.method.DigitsKeyListener +import android.view.MotionEvent +import android.view.View +import android.widget.EditText +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.annotation.StyleRes +import com.google.android.material.textfield.TextInputLayout +import com.redmadrobot.inputmask.MaskedTextChangedListener +import ru.touchin.roboswag.views.input.BaseTextInputView +import ru.touchin.roboswag.views.input.FocusChangeListenerContainer +import ru.touchin.roboswag.views.input.HintedMaskedEditTextListener +import ru.touchin.roboswag.views.input.MaskedHintEditText +import ru.touchin.roboswag.views.input.TextWatcherContainer +import ru.touchin.roboswag.views.input.TouchEventListenerContainer +import ru.touchin.roboswag.views.input.TouchListener +import ru.touchin.roboswag.views.input.delegates.bottom_hint.BottomHintState +import ru.touchin.roboswag.views.input.delegates.bottom_hint.InputSupportHintDelegate +import ru.touchin.roboswag.views.input.delegates.bottom_hint.InputSupportHintDelegateImpl +import ru.touchin.roboswag.views.input.delegates.bottom_hint.ShowSupportHintMode +import setMargins + +@Suppress("detekt.TooManyFunctions") +class InputEditorDelegateImpl : InputEditorDelegate { + + private companion object { + const val DRAWABLE_END = 2 // Индекс правого drawable в compoundDrawables TextView + const val TEXT_INPUT_LAYOUT_TAG = "text_input_layout" + const val EDIT_TEXT_TAG = "edit_text" + const val UNDERLINE_TAG = "underline" + const val SUPPORT_HINT_TAG = "support_hint" + const val SUPPORT_HINT_START_IMAGE_TAG = "support_hint_start_image" + } + + private var hintColor: Int = InputSupportHintDelegate.DEFAULT_ERROR_COLOR + + private lateinit var textInputLayout: TextInputLayout + private lateinit var supportHintTextView: TextView + private lateinit var supportHintStartImage: ImageView + private lateinit var editText: MaskedHintEditText + private lateinit var underline: View + + private var extractedText: String? = null + private var maskIsFilled: Boolean? = null + private var maskFieldListener: MaskFilledListener? = null + private var onIconClickListener: (() -> Unit)? = null + + private val focusChangeListenerContainer = FocusChangeListenerContainer() + private val textWatcherContainer = TextWatcherContainer() + private val touchEventListenerContainer = TouchEventListenerContainer() + + private val supportHintDelegate = InputSupportHintDelegateImpl() + + @SuppressLint("ClickableViewAccessibility") + override fun bindWithBaseTextInputView(baseTextInputView: BaseTextInputView) { + textInputLayout = baseTextInputView.findViewWithTag(TEXT_INPUT_LAYOUT_TAG) + editText = baseTextInputView.findViewWithTag(EDIT_TEXT_TAG) + underline = baseTextInputView.findViewWithTag(UNDERLINE_TAG) + supportHintTextView = baseTextInputView.findViewWithTag(SUPPORT_HINT_TAG) + supportHintStartImage = baseTextInputView.findViewWithTag(SUPPORT_HINT_START_IMAGE_TAG) + + textInputLayout.id = View.generateViewId() + editText.id = View.generateViewId() + underline.id = View.generateViewId() + supportHintTextView.id = View.generateViewId() + supportHintStartImage.id = View.generateViewId() + + supportHintDelegate.init(textInputLayout, underline, this, supportHintTextView, supportHintStartImage) + + editText.onFocusChangeListener = focusChangeListenerContainer + editText.setOnTouchListener(touchEventListenerContainer) + + baseTextInputView.addValidateListener( + onSuccess = { + supportHintDelegate.clearCurrentError() + }, + onFailure = { _, errorId -> + clearSupportHintStartDrawable() + showErrorHintText(errorId) + } + ) + } + + override fun getEditText(): EditText = editText + + override fun getExtractedText(): String = extractedText ?: editText.text.toString() + + override fun getRawText(): String = editText.getRawText() + + override fun setText(text: String?) = editText.setText(text) + + override fun getSelectionStart(): Int = editText.selectionStart + + override fun setSelectionStart(index: Int) = editText.setSelection(index) + + override fun setSelectorToEnd() = editText.setSelection(getRawText().length) + + override fun setHint(text: String?) { + textInputLayout.hint = text + } + + override fun setHintColor(color: Int) { + hintColor = color + textInputLayout.defaultHintTextColor = ColorStateList.valueOf(color) + } + + override fun setHintColorId(@ColorRes colorId: Int) = setHintColor(editText.context.getColor(colorId)) + + override fun setHintAppearance(@StyleRes style: Int?) { + if (style == null) return + textInputLayout.setHintTextAppearance(style) + } + + override fun getCurrentHintTextColor(): Int = editText.currentTextColor + + override fun setFilters(vararg filters: InputFilter) { + editText.filters = editText.filters + .toMutableList() + .apply { addAll(filters) } + .toTypedArray() + } + + override fun setLongClickable(longClickable: Boolean) { + editText.isLongClickable = longClickable + } + + override fun setEnabled(enabled: Boolean) { + editText.isEnabled = enabled + } + + override fun setOnEditorActionListener(listener: TextView.OnEditorActionListener) { + editText.setOnEditorActionListener(listener) + } + + @SuppressLint("ClickableViewAccessibility") + override fun setOnTouchListener(listener: View.OnTouchListener?) { + touchEventListenerContainer.setMainTouchEventListener(listener) + } + + override fun setSupportTouchListener(listener: TouchListener) { + touchEventListenerContainer.add(listener) + } + + override fun setFocusable(focusable: Boolean) { + editText.isFocusable = focusable + } + + override fun setFocusableInTouchMode(focusable: Boolean) { + editText.isFocusableInTouchMode = focusable + } + + override fun hasFocus(): Boolean = editText.hasFocus() + + override fun clearFocus() = editText.clearFocus() + + override fun requestFocus(direction: Int, previouslyFocusedRect: Rect?): Boolean = + editText.requestFocus(direction, previouslyFocusedRect) + + @Suppress("ClickableViewAccessibility", "LabeledExpression") + override fun setOnIconClickListener(listener: (() -> Unit)?) { + onIconClickListener = listener + + editText.setOnTouchListener { _, event -> + val result = isEndDrawableIconClicked(event) + + if (!result) { + onIconClickListener?.invoke() + } + return@setOnTouchListener result + } + } + + override fun isEndDrawableIconClicked(event: MotionEvent): Boolean { + val drawableWidth = editText.compoundDrawables[DRAWABLE_END]?.bounds + ?.width() + ?: return false + + val drawableStartX = editText.width - drawableWidth - editText.paddingEnd + + return event.action == MotionEvent.ACTION_UP && event.rawX >= drawableStartX + } + + override fun setOnFocusChangeListener(listener: View.OnFocusChangeListener?) { // TODO проблема нейминга set/add + listener?.let(focusChangeListenerContainer::add) + } + + override fun removeOnFocusChangeListener(listener: View.OnFocusChangeListener) { + focusChangeListenerContainer.remove(listener) + } + + override fun setTextColor(color: Int) { + editText.setTextColor(color) + } + + override fun setTextAppearance(@StyleRes style: Int?) { + if (style == null) return + editText.setTextAppearance(style) + } + + override fun setInputType(type: Int) { + editText.inputType = type + } + + override fun setImeOptions(imeOptions: Int) { + editText.imeOptions = imeOptions + } + + override fun setDigits(digit: String) { + if (digit.isBlank()) return + editText.keyListener = DigitsKeyListener.getInstance(digit) + } + + override fun setMaxLines(maxLines: Int) { + editText.maxLines = maxLines + } + + override fun setMaxLength(maxLength: Int) { + editText.filters = arrayOf(InputFilter.LengthFilter(maxLength)) + } + + override fun setDrawableEnd(@DrawableRes drawable: Int) { + editText.setCompoundDrawablesWithIntrinsicBounds(0, 0, drawable, 0) + } + + override fun setDrawableEnd(drawable: Drawable?) { + editText.setCompoundDrawablesWithIntrinsicBounds(null, null, drawable, null) + } + + override fun setEditTextBackgroundTint(color: Int) { + editText.backgroundTintList = ColorStateList.valueOf(color) + } + + override fun setEditTextBackgroundTintId(@ColorRes colorId: Int) = setEditTextBackgroundTint(editText.context.getColor(colorId)) + + override fun addTextChangedListener(watcher: TextWatcher) { + when (watcher) { + is HintedMaskedEditTextListener -> setupMask(watcher) + else -> textWatcherContainer.add(watcher) + } + } + + override fun removeTextChangedListener(watcher: TextWatcher) { + when (watcher) { + is HintedMaskedEditTextListener -> { + editText.removeTextChangedListener(watcher) + editText.addTextChangedListener(textWatcherContainer) + } + + else -> { + textWatcherContainer.remove(watcher) + } + } + } + + override fun setupMask(format: String, autocomplete: Boolean) = setupMask(createMask(format, autocomplete)) + + private fun createMask(format: String, autocomplete: Boolean) = HintedMaskedEditTextListener( + format = format, + field = editText, + autocomplete = autocomplete, + valueListener = object : MaskedTextChangedListener.ValueListener { + override fun onTextChanged(maskFilled: Boolean, extractedValue: String) { + extractedText = extractedValue + maskIsFilled = maskFilled + maskFieldListener?.onFilled(maskFilled, extractedValue) + } + } + ) + + override fun setupMask(maskListener: HintedMaskedEditTextListener?) { + maskFieldListener = null + extractedText = null + + editText.removeTextChangedListener(textWatcherContainer) + + val rootTextWatcher = when (maskListener != null) { + true -> maskListener.apply { listener = textWatcherContainer } + false -> textWatcherContainer + } + + editText.addTextChangedListener(rootTextWatcher) + } + + override fun setOnMaskFilledListener(listener: MaskFilledListener?) { + maskFieldListener = listener + } + + override fun setOnMaskFilledListener(listener: (Boolean, String) -> Unit) { + maskFieldListener = object : MaskFilledListener { + override fun onFilled(maskFilled: Boolean, extractedText: String) { + listener.invoke(maskFilled, extractedText) + } + } + } + + override fun setMaskedHint(text: String) { + editText.maskedHint = text + } + + override fun setMaskedHintColor(color: Int) = editText.setMaskedHintColor(color) + + override fun setMaskedHintColorId(@ColorRes colorId: Int) = setMaskedHintColor(editText.context.getColor(colorId)) + + override fun maskIsFilled(): Boolean? = maskIsFilled + + override fun setEditTextMargin(start: Int, top: Int, end: Int, bottom: Int) = + editText.setMargins(start, top, end, bottom) + + override fun setEditTextMargin(margin: Int) = setEditTextMargin(margin, margin, margin, margin) + + override fun setEditTextPadding(start: Int, top: Int, end: Int, bottom: Int) = + editText.setPadding(start, top, end, bottom) + + override fun setEditTextPadding(padding: Int) = setEditTextPadding(padding, padding, padding, padding) + + override fun getEditTextPaddingStart(): Int = editText.paddingStart + + override fun getEditTextPaddingEnd(): Int = editText.paddingEnd + + override fun getEditTextPaddingTop(): Int = editText.paddingTop + + override fun getEditTextPaddingBottom(): Int = editText.paddingBottom + + // START SupportHint delegate region + override fun showSupportHint() = supportHintDelegate.showSupportHint() + + override fun setInformationHintText(text: String?) = supportHintDelegate.setInformationHintText(text) + + override fun setSupportHintMode(mode: ShowSupportHintMode) = supportHintDelegate.setSupportHintMode(mode) + + override fun supportHintIsShow(): Boolean = supportHintDelegate.supportHintIsShow() + + override fun getSupportHintText(): String? = supportHintDelegate.getSupportHintText() + + override fun setSupportHintAppearance(@StyleRes style: Int?) = supportHintDelegate.setSupportHintAppearance(style) + + override fun showErrorHintText(@StringRes errorText: Int) = showErrorHintText(editText.context.getString(errorText)) + + override fun showErrorHintText(errorText: String?) = supportHintDelegate.showErrorHintText(errorText) + + override fun getSupportHintState(): BottomHintState = supportHintDelegate.getSupportHintState() + + override fun hasError(): Boolean = supportHintDelegate.hasError() + + override fun setSupportHintText(text: String?) = supportHintDelegate.setSupportHintText(text) + + override fun setSupportHintText(text: String?, state: BottomHintState) = supportHintDelegate.setSupportHintText(text, state) + + override fun setSupportHintText(textId: Int) = supportHintDelegate.setSupportHintText(textId) + + override fun setSupportHintTextColor(color: Int) = supportHintDelegate.setSupportHintTextColor(color) + + override fun setSupportHintTextColorId(colorId: Int) = supportHintDelegate.setSupportHintTextColorId(colorId) + + override fun setSupportHintStartDrawable(drawableId: Int) = supportHintDelegate.setSupportHintStartDrawable(drawableId) + + override fun setSupportHintStartDrawable(drawable: Drawable?) = supportHintDelegate.setSupportHintStartDrawable(drawable) + + override fun setSupportHintStartDrawable(drawable: Drawable?, sizePx: Int?, gravityInt: Int?, marginPx: Int?) = + supportHintDelegate.setSupportHintStartDrawable(drawable, sizePx, gravityInt, marginPx) + + override fun clearSupportHintStartDrawable() = supportHintDelegate.clearSupportHintStartDrawable() + + override fun showInformationHint() = supportHintDelegate.showInformationHint() + + override fun showInformationHint(text: String?) = supportHintDelegate.showInformationHint(text) + + override fun setupInformationColor(color: Int) = supportHintDelegate.setupInformationColor(color) + + override fun setupInformationColorId(colorId: Int) = supportHintDelegate.setupInformationColor(colorId) + + override fun setupErrorColor(color: Int) = supportHintDelegate.setupErrorColor(color) + + override fun setErrorColorId(@ColorRes colorId: Int) = setupErrorColor(editText.context.getColor(colorId)) + + override fun isFloating(isFloating: Boolean) = supportHintDelegate.isFloating(isFloating) + + override fun hideSupportHint() = supportHintDelegate.hideSupportHint() + + override fun setSupportHintEnable(isEnable: Boolean) = supportHintDelegate.setSupportHintEnable(isEnable) + // End SupportHint delegate region + +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/delegates/edit_text/MaskFilledListener.kt b/views/src/main/java/ru/touchin/roboswag/views/input/delegates/edit_text/MaskFilledListener.kt new file mode 100644 index 0000000..66226f7 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/delegates/edit_text/MaskFilledListener.kt @@ -0,0 +1,6 @@ +package ru.touchin.roboswag.views.input.delegates.edit_text + +interface MaskFilledListener { + + fun onFilled(maskFilled: Boolean, extractedText: String) +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/delegates/validators/BaseInputValidatorDelegate.kt b/views/src/main/java/ru/touchin/roboswag/views/input/delegates/validators/BaseInputValidatorDelegate.kt new file mode 100644 index 0000000..d374d7a --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/delegates/validators/BaseInputValidatorDelegate.kt @@ -0,0 +1,57 @@ +package ru.touchin.roboswag.views.input.delegates.validators + +import ru.touchin.roboswag.views.input.validatable.listener.ValidatorListener +import ru.touchin.roboswag.views.input.validatable.listener.implementation.SimpleValidatorListener +import ru.touchin.roboswag.views.input.validatable.validator.ValidateResult +import ru.touchin.roboswag.views.input.validatable.validator.Validator +import ru.touchin.roboswag.views.input.validatable.ValidatorListenerContainer + +abstract class BaseInputValidatorDelegate : InputValidatorDelegate { + + protected var currentValidator: Validator? = null + protected var validatorListeners = ValidatorListenerContainer() + protected var needValidateFlag: Boolean = true + protected var validateFlag: Int = InputValidatorDelegate.FLAG_NONE + + override fun manualValidate() { + currentValidator?.let { validator -> validate(validator = validator, isManualValidate = true) } + } + + override fun validate() { + currentValidator?.let(this::validate) + } + + override fun validate(validator: Validator) = validate(validator = validator, isManualValidate = false) + + abstract fun validate(validator: Validator, isManualValidate: Boolean) + + override fun getValidateModeFlag(): Int = validateFlag + + override fun setValidatorMode(flagMode: Int) { + validateFlag = flagMode + } + + override fun hasNeedToValidateFlag(): Boolean = needValidateFlag + + override fun setNeedToValidateFlag(isNeed: Boolean) { + needValidateFlag = isNeed + } + + override fun setValidator(validator: Validator?) { + currentValidator = validator?.apply { addListener(validatorListeners) } + } + + override fun addValidateListener(onSuccess: ((String) -> Unit)?, onFailure: ((String, Int) -> Unit)?) = addValidateListener( + SimpleValidatorListener { result -> + when (result) { + is ValidateResult.Success -> onSuccess?.invoke(result.data) + is ValidateResult.Error -> onFailure?.invoke(result.data, result.errorMessageId) + } + } + ) + + override fun addValidateListener(listener: ValidatorListener) { + validatorListeners.add(listener) + } + +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/delegates/validators/InputValidatorDelegate.kt b/views/src/main/java/ru/touchin/roboswag/views/input/delegates/validators/InputValidatorDelegate.kt new file mode 100644 index 0000000..8df04f4 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/delegates/validators/InputValidatorDelegate.kt @@ -0,0 +1,59 @@ +package ru.touchin.roboswag.views.input.delegates.validators + +import ru.touchin.roboswag.views.input.validatable.listener.ValidatorListener +import ru.touchin.roboswag.views.input.validatable.validator.Validator +import ru.touchin.roboswag.views.input.delegates.edit_text.InputEditorDelegate + +interface InputValidatorDelegate { + + companion object { + /** + *
+         * |-------|-------|-------|-------|
+         *                                   FLAG_NONE
+         *                                 1 FLAG_HAS_FOCUS
+         *                                1  FLAG_LOSS_FOCUS
+         *                               1   FLAG_BEFORE_TEXT_CHANGED
+         *                              1    FLAG_ON_TEXT_CHANGED
+         *                             1     FLAG_AFTER_TEXT_CHANGED
+         * |-------|-------|-------|-------|
+ */ + + const val FLAG_NONE = 0b0000000000 // 0 + const val FLAG_HAS_FOCUS = 0b0000000001 // 1 + const val FLAG_LOSS_FOCUS = 0b0000000010 // 2 + const val FLAG_BEFORE_TEXT_CHANGED = 0b0000000100 // 4 + const val FLAG_ON_TEXT_CHANGED = 0b0000001000 // 8 + const val FLAG_AFTER_TEXT_CHANGED = 0b0000010000 // 16 + } + + fun bindWithEditorDelegate(inputEditorDelegate: InputEditorDelegate) + + fun manualValidate() + + fun validate() + + fun validate(validator: Validator) + + fun setNeedToValidateFlag(isNeed: Boolean) + + fun setValidator(validator: Validator?) + + fun setValidatorMode(flagMode: Int) + + fun getValidateModeFlag(): Int + + fun actionWithDisableValidate(action: () -> Unit) + + fun addValidateListener(listener: ValidatorListener) + + fun addSuccessOrNullValidateListener(listener: (String?) -> Unit) + + fun addValidateListener( + onSuccess: ((String) -> Unit)? = null, + onFailure: ((String, Int) -> Unit)? = null + ) + + fun hasNeedToValidateFlag(): Boolean +} + diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/delegates/validators/InputValidatorDelegateImpl.kt b/views/src/main/java/ru/touchin/roboswag/views/input/delegates/validators/InputValidatorDelegateImpl.kt new file mode 100644 index 0000000..e27d223 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/delegates/validators/InputValidatorDelegateImpl.kt @@ -0,0 +1,31 @@ +package ru.touchin.roboswag.views.input.delegates.validators + +import ru.touchin.roboswag.views.input.validatable.listener.implementation.SuccessOrNullValidatorListener +import ru.touchin.roboswag.views.input.delegates.edit_text.InputEditorDelegate +import ru.touchin.roboswag.views.input.validatable.validator.Validator + +class InputValidatorDelegateImpl : BaseInputValidatorDelegate() { + + private lateinit var inputEditorDelegate: InputEditorDelegate + + override fun bindWithEditorDelegate(inputEditorDelegate: InputEditorDelegate) { + this.inputEditorDelegate = inputEditorDelegate + } + + override fun validate(validator: Validator, isManualValidate: Boolean) { + if (!needValidateFlag && !isManualValidate) return + + validator.validate(text = inputEditorDelegate.getExtractedText()) + } + + override fun addSuccessOrNullValidateListener(listener: (String?) -> Unit) = addValidateListener( + SuccessOrNullValidatorListener { result -> listener.invoke(result) } + ) + + override fun actionWithDisableValidate(action: () -> Unit) { + val previousNeedValidateFlag = needValidateFlag + needValidateFlag = false + action.invoke() + needValidateFlag = previousNeedValidateFlag + } +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/delegates/validators/ValidatorTypes.kt b/views/src/main/java/ru/touchin/roboswag/views/input/delegates/validators/ValidatorTypes.kt new file mode 100644 index 0000000..75eb89e --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/delegates/validators/ValidatorTypes.kt @@ -0,0 +1,13 @@ +package ru.touchin.roboswag.views.input.delegates.validators + +enum class ValidatorTypes { + NONE, + PHONE, + DATE, + TIME, + EMPTY, + EMAIL, + LATIN, + CYRILLIC, + NUMBER +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/utils/DateValidationUtils.kt b/views/src/main/java/ru/touchin/roboswag/views/input/utils/DateValidationUtils.kt new file mode 100644 index 0000000..a7ee9f3 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/utils/DateValidationUtils.kt @@ -0,0 +1,134 @@ +package ru.touchin.roboswag.views.input.utils + +import org.joda.time.DateTime +import org.joda.time.LocalDate +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Locale + +/** + * This util allows you to check the validity of the entered date as you enter each character. + * This util checks ranges [01-31].[01-12].[1900-2099]. This means that the value "31.02.yyyy" + * will be considered valid until the date is fully entered. + * Use it only for date format "dd.mm.yyyy". + **/ +object DateValidationUtils { + private const val DAY_INDEX = 0 + private const val MONTH_INDEX = 1 + private const val YEAR_INDEX = 2 + + private const val SHORT_DATE_FORMAT = "dd.MM.yyyy" + + // ^$ empty string - correct + // [0-3] as first char - correct + // (0)[1-9] is [01; 09] range, excluding "00" + // [1-2][0-9] is [10; 20] range + // (3)[0-1] is [30;31] range + private const val DAY_PATTERN = "^$|[0-3]|((0)[1-9]|[1-2][0-9]|(3)[0-1])(.)" + + // ^$ empty string - correct + // [0-1] as first char - correct + // (0)[1-9] is [01; 09] range, excluding "00" + // (1)[0-2] is [10; 12] range + private const val MONTH_PATTERN = "^$|[0-1]|((0)[1-9]|(1)[0-2])(.)" + + // ^$ empty string - correct + // [1-2] as first char - correct + // (19)|(20) is [19; 20] range + // (19)[0-9] is [190; 199] range + // (19)[0-9][0-9] is [1900; 1999] range + // (20)[0-9] is [200; 209] range + // (20)[0-9][0-9] is [2000; 2099] range + private const val YEAR_PATTERN = "^$|[1-2]|(19)|(20)|(19)[0-9]|(19)[0-9][0-9]|(20)[0-9]|(20)[0-9][0-9]" + + // "" + private const val MIN_DAY_STRING_PART_INDEX = 0 + + // "dd." + private const val MAX_DAY_STRING_PART_INDEX = 3 + + // "dd.m" + private const val MIN_MONTH_STRING_PART_INDEX = 3 + + // "dd.mm." + private const val MAX_MONTH_STRING_PART_INDEX = 6 + + // "dd.mm.y" + private const val MIN_YEAR_STRING_PART_INDEX = 6 + + // "dd.mm.yyyy" + private const val MAX_YEAR_STRING_PART_INDEX = 10 + + fun isDatePreValid(dateString: String): Boolean { + val dayStringPart = getDayStringPart(dateString) + val monthStringPart = getMonthStringPart(dateString) + val yearStringPart = getYearStringPart(dateString) + + val dayPattern = DAY_PATTERN.toRegex() + val monthPattern = MONTH_PATTERN.toRegex() + val yearPattern = YEAR_PATTERN.toRegex() + + val isDayValid = dayPattern.matches(dayStringPart) + val isMonthValid = monthPattern.matches(monthStringPart) + val isYearValid = yearPattern.matches(yearStringPart) + + val isDateExist = isDateExist(dateString) + + return isDayValid && isMonthValid && isYearValid && isDateExist + + } + + private fun getYearStringPart(dateString: String): String = if (dateString.length > MIN_YEAR_STRING_PART_INDEX) { + dateString.substring(MIN_YEAR_STRING_PART_INDEX, minOf(MAX_YEAR_STRING_PART_INDEX, dateString.length)) + } else { + "" + } + + private fun getMonthStringPart(dateString: String): String = if (dateString.length >= MIN_MONTH_STRING_PART_INDEX) { + dateString.substring(MIN_MONTH_STRING_PART_INDEX, minOf(MAX_MONTH_STRING_PART_INDEX, dateString.length)) + } else { + "" + } + + private fun getDayStringPart(dateString: String) = + dateString.substring(MIN_DAY_STRING_PART_INDEX, minOf(MAX_DAY_STRING_PART_INDEX, dateString.length)) + + private fun isDateExist(dateString: String): Boolean { + if (dateString.length == MAX_YEAR_STRING_PART_INDEX) { + try { + dateString.toShortFormatDateTime() + } catch (exception: ParseException) { + return false + } + } + return true + } + + fun String.toShortFormatDateTime(): DateTime { + val format = SimpleDateFormat(SHORT_DATE_FORMAT, Locale.ROOT) + format.isLenient = false + + // Don't use DateTime(format.parse(this)), cause it doesn't work correctly with dates that were >= 110 year ago + return DateTime().withDate(LocalDate.fromDateFields(format.parse(this))).withTime(0, 0, 0, 0) + } + + @Suppress("TooGenericExceptionCaught") + fun String.toDateShortFormatOrNull(): DateTime? { + val dateSplit = this.split(".") + + return try { + if (dateSplit.size == 3) { + val day = dateSplit[DAY_INDEX].toInt() + val month = dateSplit[MONTH_INDEX].toInt() + val year = dateSplit[YEAR_INDEX].toInt() + + DateTime().withDate(year, month, day).withTime(0, 0, 0, 0) + } else { + null + } + } catch (ex: Exception) { + null + } + } + +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/utils/EmailUtil.kt b/views/src/main/java/ru/touchin/roboswag/views/input/utils/EmailUtil.kt new file mode 100644 index 0000000..532a179 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/utils/EmailUtil.kt @@ -0,0 +1,150 @@ +package ru.touchin.roboswag.views.input.utils + +import java.util.regex.Pattern + +object EmailUtil { + + private const val EMAIL_PARTS_COUNT = 3 + private const val MAIN_EMAIL_PARTS_COUNT = 2 + + private const val LOCAL_INDEX = 0 + private const val DOMAIN_INDEX = 1 + + private const val NAME_DOMAIN_EMAIL_INDEX = 1 + private const val HOST_DOMAIN_EMAIL_INDEX = 2 + + private const val HOST_MIN_SIZE = 3 // include dot + private const val HOST_MAX_SIZE = 25 + private const val NAME_MIN_SIZE = 3 // include @ + private const val NAME_MAX_SIZE = 64 + private const val LOCAL_MIN_SIZE = 1 + + private const val NOT_FOUND = -1 + + private const val BASE_SYMBOLS = "a-zA-Z0-9" + private const val TWO_DOTS = ".." // TODO как-нибудь занести в регулярку проверку на вхождение двух точек + + private const val LOCAL_PART_PATTERN: String = "^[$BASE_SYMBOLS\\+\\.\\_\\%\\-]" + private const val DOMAIN_PART_PATTERN: String = "\\@[[^\\-.]&&$BASE_SYMBOLS\\.][$BASE_SYMBOLS\\-]" + private const val HOST_PATTERN: String = "\\.[$BASE_SYMBOLS]" + + private const val PRE_LOCAL_PART_EMAIL: String = "$LOCAL_PART_PATTERN+" + private const val PRE_DOMAIN_PART_EMAIL: String = "$DOMAIN_PART_PATTERN*" + private const val PRE_HOST_EMAIL: String = "$HOST_PATTERN*" + + // Pattern for email validation + // 1. local-part [a-zA-Z0-9\+\.\_\%\-]{1,64} + // - uppercase and lowercase Latin letters A to Z and a to z + // - digits 0 to 9 + // - printable characters - _ % + - . + // from 1 to 64 symbols + private const val LOCAL_PART_EMAIL: String = "$LOCAL_PART_PATTERN{1,64}" + + // 2. domain-part [a-zA-Z0-9][a-zA-Z0-9\-]{1,64} + // - uppercase and lowercase Latin letters A to Z and a to z + // - digits 0 to 9 and '-' not in the beginning + // from 2 to 64 symbols + private const val DOMAIN_PART_EMAIL: String = "$DOMAIN_PART_PATTERN{1,63}" + + // 3. (.[a-zA-Z0-9][a-zA-Z0-9\-]{2,24}) group repeats 1 or more times + // - dot and uppercase and lowercase Latin letters A to Z and a to z + // - digits 2 to 9 + // from 3 to 25 symbols (включая точку) + private const val HOST_EMAIL: String = "$HOST_PATTERN{2,24}$" + + val EMAIL_PATTERN_REGEX = "$LOCAL_PART_EMAIL$DOMAIN_PART_EMAIL$HOST_EMAIL".toRegex() + + @Suppress("detekt.ReturnCount") + fun String.emailHaveAllParts(): Boolean { + val parts = getEmailParts(this) + val nonNullParts = parts.filterNotNull() + + if (nonNullParts.size != parts.size) return false + + val map = mapOf( + nonNullParts[LOCAL_INDEX] to PatternEmail.LOCAL, + nonNullParts[NAME_DOMAIN_EMAIL_INDEX] to PatternEmail.DOMAIN_NAME, + nonNullParts[HOST_DOMAIN_EMAIL_INDEX] to PatternEmail.DOMAIN_HOST + ) + + for ((part, partPattern) in map.entries) { + when (partPattern) { + PatternEmail.LOCAL, PatternEmail.DOMAIN_NAME -> if (part.isEmpty()) return false + PatternEmail.DOMAIN_HOST -> if (part.length < partPattern.minLength) return false + } + } + + return true + } + + fun String.containsTwoDots(): Boolean = contains(TWO_DOTS) + + private fun getEmailParts(email: String): Array { + val emailParts = arrayOfNulls(EMAIL_PARTS_COUNT) + + val mainParts = email.split("@".toRegex(), MAIN_EMAIL_PARTS_COUNT) + emailParts[LOCAL_INDEX] = mainParts.getOrNull(LOCAL_INDEX).orEmpty() + + val domainPart = mainParts.getOrNull(DOMAIN_INDEX).orEmpty() + val lastDotIndex = domainPart.lastIndexOf(".") + + val nameDomainPart = if (lastDotIndex == NOT_FOUND) domainPart else domainPart.substring(0, lastDotIndex) + emailParts[NAME_DOMAIN_EMAIL_INDEX] = if (nameDomainPart.isEmpty()) null else "@$nameDomainPart" + + val hostDomainPart = if (lastDotIndex == NOT_FOUND) "" else domainPart.substring(lastDotIndex) + emailParts[HOST_DOMAIN_EMAIL_INDEX] = hostDomainPart.ifEmpty { null } + + return emailParts + } + + fun String.emailIsPreValid(): Boolean { + val parts = getEmailParts(this) + + // Проверяет с конца + val map = mapOf( + parts[HOST_DOMAIN_EMAIL_INDEX] to PatternEmail.DOMAIN_HOST, + parts[NAME_DOMAIN_EMAIL_INDEX] to PatternEmail.DOMAIN_NAME, + parts[LOCAL_INDEX] to PatternEmail.LOCAL + ) + + var previousPart: String? = null + for ((part, partPattern) in map.entries) { + if (part == null) { + if (previousPart != null) return false + previousPart = null + continue + } + + val isValidPattern = Pattern + .compile(partPattern.pattern, Pattern.CASE_INSENSITIVE) + .matcher(part) + .matches() + + if ( + !isValidPattern + || isTooLong(part, partPattern) + || isTooSmall(part, partPattern, previousPart) + || part.containsTwoDots() + ) { + return false + } + + previousPart = part + } + + return true + } + + // Последняя часть на длину провериться не может, так как нельзя найти тригера завершенности + private fun isTooSmall(part: String, partPattern: PatternEmail, previousPart: String?): Boolean = + partPattern != PatternEmail.DOMAIN_HOST && part.length < partPattern.minLength && previousPart != null + + private fun isTooLong(part: String, partPattern: PatternEmail): Boolean = part.length > partPattern.maxLength + + private enum class PatternEmail(val pattern: String, val minLength: Int, val maxLength: Int) { + LOCAL(PRE_LOCAL_PART_EMAIL, LOCAL_MIN_SIZE, NAME_MAX_SIZE), + DOMAIN_NAME(PRE_DOMAIN_PART_EMAIL, NAME_MIN_SIZE, NAME_MAX_SIZE), + DOMAIN_HOST(PRE_HOST_EMAIL, HOST_MIN_SIZE, HOST_MAX_SIZE) + } + +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/utils/MaskConstants.kt b/views/src/main/java/ru/touchin/roboswag/views/input/utils/MaskConstants.kt new file mode 100644 index 0000000..94ec846 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/utils/MaskConstants.kt @@ -0,0 +1,15 @@ +package ru.touchin.roboswag.views.input.utils + +object MaskConstants { + + const val PHONE_MASK = "+{7} [000] [000]-[00]-[00]" + + const val PHONE_MASK_WITHOUT_INTERNATIONAL_CODE = "[000] [000]-[00]-[00]" + + const val BANK_CARD = "[0000] [0000] [0000] [0000] [999]" + + const val MID = "[0000] [0000] [0000] [0000]" + + const val DATE_MASK = "[00]{.}[00]{.}[0000]" + +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/utils/ViewExt.kt b/views/src/main/java/ru/touchin/roboswag/views/input/utils/ViewExt.kt new file mode 100644 index 0000000..288bc3f --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/utils/ViewExt.kt @@ -0,0 +1,21 @@ +import android.content.res.Resources +import android.view.View +import android.view.ViewGroup +import androidx.core.view.marginBottom +import androidx.core.view.marginEnd +import androidx.core.view.marginStart +import androidx.core.view.marginTop + +fun View.setMargins(start: Int? = marginStart, top: Int? = marginTop, end: Int? = marginEnd, bottom: Int? = marginBottom) { + val params = getMarginLayoutParams() + params.setMargins( + start?.dpToPx() ?: params.marginStart, + top?.dpToPx() ?: params.topMargin, + end?.dpToPx() ?: params.marginEnd, + bottom?.dpToPx() ?: params.bottomMargin + ) +} + +fun View.getMarginLayoutParams(): ViewGroup.MarginLayoutParams = layoutParams as ViewGroup.MarginLayoutParams + +fun Int.dpToPx() = (this * Resources.getSystem().displayMetrics.density).toInt() diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/ValidatorListenerContainer.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/ValidatorListenerContainer.kt new file mode 100644 index 0000000..84b4007 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/ValidatorListenerContainer.kt @@ -0,0 +1,12 @@ +package ru.touchin.roboswag.views.input.validatable + +import ru.touchin.roboswag.views.input.validatable.listener.ValidatorListener +import ru.touchin.roboswag.views.input.validatable.validator.ValidateResult +import ru.touchin.roboswag.views.input.BaseListenersContainer + +class ValidatorListenerContainer : BaseListenersContainer(), ValidatorListener { + + override fun onValidate(result: ValidateResult) = + listeners.forEach { validatorListener -> validatorListener.onValidate(result) } + +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/ValidatorUnit.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/ValidatorUnit.kt new file mode 100644 index 0000000..f794f8d --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/ValidatorUnit.kt @@ -0,0 +1,3 @@ +package ru.touchin.roboswag.views.input.validatable + +interface ValidatorUnit diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/listener/ValidatorListener.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/listener/ValidatorListener.kt new file mode 100644 index 0000000..87f73aa --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/listener/ValidatorListener.kt @@ -0,0 +1,9 @@ +package ru.touchin.roboswag.views.input.validatable.listener + +import ru.touchin.roboswag.views.input.validatable.validator.ValidateResult + +interface ValidatorListener { + + fun onValidate(result: ValidateResult) + +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/listener/implementation/SimpleValidatorListener.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/listener/implementation/SimpleValidatorListener.kt new file mode 100644 index 0000000..b4c93ac --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/listener/implementation/SimpleValidatorListener.kt @@ -0,0 +1,11 @@ +package ru.touchin.roboswag.views.input.validatable.listener.implementation + +import ru.touchin.roboswag.views.input.validatable.listener.ValidatorListener +import ru.touchin.roboswag.views.input.validatable.validator.ValidateResult + +open class SimpleValidatorListener(private val onValidate: (ValidateResult) -> Unit) : ValidatorListener { + + override fun onValidate(result: ValidateResult) { + onValidate.invoke(result) + } +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/listener/implementation/SuccessOrNullValidatorListener.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/listener/implementation/SuccessOrNullValidatorListener.kt new file mode 100644 index 0000000..129bb31 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/listener/implementation/SuccessOrNullValidatorListener.kt @@ -0,0 +1,10 @@ +package ru.touchin.roboswag.views.input.validatable.listener.implementation + +import ru.touchin.roboswag.views.input.validatable.listener.ValidatorListener +import ru.touchin.roboswag.views.input.validatable.validator.ValidateResult + +open class SuccessOrNullValidatorListener(private val onValidate: (String?) -> Unit) : ValidatorListener { + + override fun onValidate(result: ValidateResult) = onValidate.invoke((result as? ValidateResult.Success)?.data) + +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/listener/implementation/SuccessValidatorListener.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/listener/implementation/SuccessValidatorListener.kt new file mode 100644 index 0000000..bc95b6a --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/listener/implementation/SuccessValidatorListener.kt @@ -0,0 +1,13 @@ +package ru.touchin.roboswag.views.input.validatable.listener.implementation + +import ru.touchin.roboswag.views.input.validatable.listener.ValidatorListener +import ru.touchin.roboswag.views.input.validatable.validator.ValidateResult + +open class SuccessValidatorListener(private val onValidate: (String) -> Unit) : ValidatorListener { + + override fun onValidate(result: ValidateResult) { + if (result is ValidateResult.Success) { + onValidate.invoke(result.data) + } + } +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/mapper/DateValidateMapper.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/mapper/DateValidateMapper.kt new file mode 100644 index 0000000..366ae93 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/mapper/DateValidateMapper.kt @@ -0,0 +1,23 @@ +package ru.touchin.roboswag.views.input.validatable.mapper + +import org.joda.time.DateTime +import ru.touchin.roboswag.views.input.utils.DateValidationUtils.toDateShortFormatOrNull + +class DateValidateMapper : ValidateMapper { + + private companion object { + + val INVALID_DATE = DateTime().withDate(1900, 1, 1).minusDays(1).millis + } + + override fun map(text: String): String = getMillsFromDateShort(text) + + private fun getMillsFromDateShort(text: String): String { + val millis = text.toDateShortFormatOrNull() + ?.millis + ?: INVALID_DATE + + return millis.toString() + } + +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/mapper/ValidateMapper.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/mapper/ValidateMapper.kt new file mode 100644 index 0000000..5f2c7d8 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/mapper/ValidateMapper.kt @@ -0,0 +1,8 @@ +package ru.touchin.roboswag.views.input.validatable.mapper + +import ru.touchin.roboswag.views.input.validatable.ValidatorUnit + +interface ValidateMapper : ValidatorUnit { + + fun map(text: String): String +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/switcher/SimpleValidatorSwitcher.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/switcher/SimpleValidatorSwitcher.kt new file mode 100644 index 0000000..3787062 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/switcher/SimpleValidatorSwitcher.kt @@ -0,0 +1,9 @@ +package ru.touchin.roboswag.views.input.validatable.switcher + +import ru.touchin.roboswag.views.input.validatable.validator.Validator + +class SimpleValidatorSwitcher(private val currentValidator: () -> Validator) : ValidatorSwitcher { + + override fun getValidator(text: String): Validator = currentValidator.invoke() + +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/switcher/TextValidatorSwitcher.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/switcher/TextValidatorSwitcher.kt new file mode 100644 index 0000000..f029152 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/switcher/TextValidatorSwitcher.kt @@ -0,0 +1,9 @@ +package ru.touchin.roboswag.views.input.validatable.switcher + +import ru.touchin.roboswag.views.input.validatable.validator.Validator + +class TextValidatorSwitcher(private val currentValidator: (text: String) -> Validator) : ValidatorSwitcher { + + override fun getValidator(text: String): Validator = currentValidator.invoke(text) + +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/switcher/ValidatorSwitcher.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/switcher/ValidatorSwitcher.kt new file mode 100644 index 0000000..997d4c1 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/switcher/ValidatorSwitcher.kt @@ -0,0 +1,13 @@ +package ru.touchin.roboswag.views.input.validatable.switcher + +import ru.touchin.roboswag.views.input.validatable.ValidatorUnit +import ru.touchin.roboswag.views.input.validatable.validator.Validatable +import ru.touchin.roboswag.views.input.validatable.validator.ValidateResult +import ru.touchin.roboswag.views.input.validatable.validator.Validator + +interface ValidatorSwitcher : ValidatorUnit, Validatable { + + fun getValidator(text: String): Validator + + override fun validate(text: String): ValidateResult = getValidator(text).validate(text) +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/BaseValidator.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/BaseValidator.kt new file mode 100644 index 0000000..d953fb3 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/BaseValidator.kt @@ -0,0 +1,27 @@ +package ru.touchin.roboswag.views.input.validatable.validator + +import ru.touchin.roboswag.views.input.validatable.listener.ValidatorListener + +abstract class BaseValidator : Validator { + + protected var listeners: MutableList? = null + + override fun addListener(listener: ValidatorListener) { + listeners = listeners ?: mutableListOf() + + listeners?.add(listener) + } + + override fun removeListener(listener: ValidatorListener) { + listeners?.remove(listener) + + if (listeners.isNullOrEmpty()) { + listeners = null + } + } + + protected fun notifyAll(result: ValidateResult) = listeners?.forEach { listener -> + listener.onValidate(result) + } + +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/Validatable.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/Validatable.kt new file mode 100644 index 0000000..23df093 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/Validatable.kt @@ -0,0 +1,7 @@ +package ru.touchin.roboswag.views.input.validatable.validator + +interface Validatable { + + fun validate(text: String): ValidateResult +} + diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/ValidateResult.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/ValidateResult.kt new file mode 100644 index 0000000..70fc24d --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/ValidateResult.kt @@ -0,0 +1,11 @@ +package ru.touchin.roboswag.views.input.validatable.validator + +import androidx.annotation.StringRes + +sealed class ValidateResult(val data: String) { + + class Success(data: String) : ValidateResult(data) + + class Error(data: String, @StringRes val errorMessageId: Int) : ValidateResult(data) + +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/Validator.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/Validator.kt new file mode 100644 index 0000000..b7261b8 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/Validator.kt @@ -0,0 +1,14 @@ +package ru.touchin.roboswag.views.input.validatable.validator + +import ru.touchin.roboswag.views.input.validatable.ValidatorUnit +import ru.touchin.roboswag.views.input.validatable.listener.ValidatorListener + +interface Validator : ValidatorUnit, Validatable { + + override fun validate(text: String): ValidateResult + + fun addListener(listener: ValidatorListener) + + fun removeListener(listener: ValidatorListener) + +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/BasePredicateValidator.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/BasePredicateValidator.kt new file mode 100644 index 0000000..70ac580 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/BasePredicateValidator.kt @@ -0,0 +1,12 @@ +package ru.touchin.roboswag.views.input.validatable.validator.implementation + +import ru.touchin.roboswag.views.input.validatable.validator.BaseValidator +import ru.touchin.roboswag.views.input.validatable.validator.ValidateResult + +open class BasePredicateValidator( + protected val predicate: (String) -> ValidateResult +) : BaseValidator() { + + override fun validate(text: String): ValidateResult = + predicate.invoke(text).also { result -> notifyAll(result) } +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/CharValidator.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/CharValidator.kt new file mode 100644 index 0000000..3605e21 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/CharValidator.kt @@ -0,0 +1,28 @@ +package ru.touchin.roboswag.views.input.validatable.validator.implementation + +import androidx.annotation.StringRes +import ru.touchin.roboswag.views.input.validatable.validator.ValidateResult + +@Suppress("detekt.LabeledExpression") +open class CharValidator( + private val condition: (Char) -> Boolean, + @StringRes private val errorMessage: Int +) : BasePredicateValidator( + predicate = { text -> + var result = true + + run breaking@{ + text.forEach { char -> + if (!condition(char)) { + result = false + return@breaking + } + } + } + + when (result) { + true -> ValidateResult.Success(text) + false -> ValidateResult.Error(text, errorMessage) + } + } +) diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/ContainerValidator.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/ContainerValidator.kt new file mode 100644 index 0000000..a304f73 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/ContainerValidator.kt @@ -0,0 +1,39 @@ +package ru.touchin.roboswag.views.input.validatable.validator.implementation + +import ru.touchin.roboswag.views.input.validatable.ValidatorUnit +import ru.touchin.roboswag.views.input.validatable.mapper.ValidateMapper +import ru.touchin.roboswag.views.input.validatable.validator.BaseValidator +import ru.touchin.roboswag.views.input.validatable.validator.Validatable +import ru.touchin.roboswag.views.input.validatable.validator.ValidateResult + +open class ContainerValidator( + private val validatorUnits: List +) : BaseValidator() { + + constructor(vararg validatorUnits: ValidatorUnit) : this(validatorUnits.toList()) + + @Suppress("detekt.LabeledExpression", "detekt.NestedBlockDepth") + override fun validate(text: String): ValidateResult { + var currentText = text + + val validateResult: ValidateResult = run breaking@{ + validatorUnits.forEach { unit -> + when (unit) { + is ValidateMapper -> currentText = unit.map(currentText) + is Validatable -> unit.validate(currentText).let { result -> + when (result) { + is ValidateResult.Error -> return@breaking ValidateResult.Error(text, result.errorMessageId) + is ValidateResult.Success -> currentText = result.data + } + } + } + } + + return@breaking ValidateResult.Success(text) + } + + notifyAll(validateResult) + + return validateResult + } +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/CyrillicValidator.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/CyrillicValidator.kt new file mode 100644 index 0000000..22975ef --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/CyrillicValidator.kt @@ -0,0 +1,14 @@ +package ru.touchin.roboswag.views.input.validatable.validator.implementation + +import androidx.annotation.StringRes + +class CyrillicValidator( + @StringRes errorMessage: Int +) : RegexValidator( + regex = CYRILLIC_PATTERN.toRegex(), + errorMessage = errorMessage +) { + private companion object { + const val CYRILLIC_PATTERN = "^[а-яА-Я]+\$" + } +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/DateFormatValidator.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/DateFormatValidator.kt new file mode 100644 index 0000000..fd414a8 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/DateFormatValidator.kt @@ -0,0 +1,33 @@ +package ru.touchin.roboswag.views.input.validatable.validator.implementation + +import androidx.annotation.StringRes +import java.util.regex.Pattern + +class DateFormatValidator( + @StringRes errorMessage: Int +) : PatternValidator( + pattern = Pattern.compile("$DAY_PATTERN$MONTH_PATTERN$YEAR_PATTERN"), + errorMessage = errorMessage +) { + private companion object { + // [0-3] as first char - correct + // (0)[1-9] is [01; 09] range, excluding "00" + // [1-2][0-9] is [10; 20] range + // (3)[0-1] is [30;31] range + const val DAY_PATTERN = "[0-3]|((0)[1-9]|[1-2][0-9]|(3)[0-1])(.)" + + // ^$ empty string - correct + // [0-1] as first char - correct + // (0)[1-9] is [01; 09] range, excluding "00" + // (1)[0-2] is [10; 12] range + const val MONTH_PATTERN = "[0-1]|((0)[1-9]|(1)[0-2])(.)" + + // [1-2] as first char - correct + // (19)|(20) is [19; 20] range + // (19)[0-9] is [190; 199] range + // (19)[0-9][0-9] is [1900; 1999] range + // (20)[0-9] is [200; 209] range + // (20)[0-9][0-9] is [2000; 2099] range + const val YEAR_PATTERN = "[1-2]|(19)|(20)|(19)[0-9]|(19)[0-9][0-9]|(20)[0-9]|(20)[0-9][0-9]" + } +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/DiapasonLengthValidator.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/DiapasonLengthValidator.kt new file mode 100644 index 0000000..e8943c4 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/DiapasonLengthValidator.kt @@ -0,0 +1,12 @@ +package ru.touchin.roboswag.views.input.validatable.validator.implementation + +import androidx.annotation.StringRes + +class DiapasonLengthValidator( + private val minLength: Int, + private val maxLength: Int, + @StringRes errorMessage: Int +) : SimpleValidator( + condition = { text -> text.length in minLength..maxLength }, + errorMessage = errorMessage +) diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/EmailValidator.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/EmailValidator.kt new file mode 100644 index 0000000..3f70d14 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/EmailValidator.kt @@ -0,0 +1,15 @@ +package ru.touchin.roboswag.views.input.validatable.validator.implementation + +import androidx.annotation.StringRes +import ru.touchin.roboswag.views.input.utils.EmailUtil +import ru.touchin.roboswag.views.input.utils.EmailUtil.containsTwoDots + +class EmailValidator( + @StringRes errorMessage: Int +) : SimpleValidator( + condition = { email -> + // TODO убрать containsTwoDots в регулярку + EmailUtil.EMAIL_PATTERN_REGEX.matches(email) && !email.containsTwoDots() + }, + errorMessage = errorMessage +) diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/EmptyValidator.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/EmptyValidator.kt new file mode 100644 index 0000000..999d409 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/EmptyValidator.kt @@ -0,0 +1,10 @@ +package ru.touchin.roboswag.views.input.validatable.validator.implementation + +import androidx.annotation.StringRes + +open class EmptyValidator( + @StringRes errorMessage: Int +) : SimpleValidator( + condition = { text -> text.isNotEmpty() }, + errorMessage = errorMessage +) diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/LatinValidator.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/LatinValidator.kt new file mode 100644 index 0000000..03ac871 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/LatinValidator.kt @@ -0,0 +1,14 @@ +package ru.touchin.roboswag.views.input.validatable.validator.implementation + +import androidx.annotation.StringRes + +class LatinValidator( + @StringRes errorMessage: Int +) : RegexValidator( + regex = LATIN_PATTERN.toRegex(), + errorMessage = errorMessage +) { + private companion object { + const val LATIN_PATTERN = "^[a-zA-Z]+\$" + } +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/LengthValidator.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/LengthValidator.kt new file mode 100644 index 0000000..ea8c43f --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/LengthValidator.kt @@ -0,0 +1,11 @@ +package ru.touchin.roboswag.views.input.validatable.validator.implementation + +import androidx.annotation.StringRes + +class LengthValidator( + private val length: Int, + @StringRes errorMessage: Int +) : SimpleValidator( + condition = { text -> text.length == length }, + errorMessage = errorMessage +) diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/LowerCaseValidator.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/LowerCaseValidator.kt new file mode 100644 index 0000000..b503dbc --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/LowerCaseValidator.kt @@ -0,0 +1,10 @@ +package ru.touchin.roboswag.views.input.validatable.validator.implementation + +import androidx.annotation.StringRes + +class LowerCaseValidator( + @StringRes errorMessage: Int +) : CharValidator( + condition = { char -> char.isLetter() && char.isLowerCase() }, + errorMessage = errorMessage +) diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/MaxDateValidator.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/MaxDateValidator.kt new file mode 100644 index 0000000..48fdfaf --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/MaxDateValidator.kt @@ -0,0 +1,16 @@ +package ru.touchin.roboswag.views.input.validatable.validator.implementation + +import androidx.annotation.StringRes +import org.joda.time.DateTime + +class MaxDateValidator( + private val maxDate: Long, + @StringRes val errorMessage: Int +) : SimpleValidator( + condition = { text -> + text.toLongOrNull() + ?.let { dateInMills -> DateTime(dateInMills).isBefore(maxDate) } + ?: false + }, + errorMessage = errorMessage +) diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/MinDateValidator.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/MinDateValidator.kt new file mode 100644 index 0000000..b7599eb --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/MinDateValidator.kt @@ -0,0 +1,15 @@ +package ru.touchin.roboswag.views.input.validatable.validator.implementation + +import androidx.annotation.StringRes + +class MinDateValidator( + private val minDate: Long, + @StringRes val errorMessage: Int +) : SimpleValidator( + condition = { text -> + text.toLongOrNull() + ?.let { dateInMills -> dateInMills >= minDate } + ?: false + }, + errorMessage = errorMessage +) diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/NameValidator.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/NameValidator.kt new file mode 100644 index 0000000..b4e4157 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/NameValidator.kt @@ -0,0 +1,23 @@ +package ru.touchin.roboswag.views.input.validatable.validator.implementation + +import androidx.annotation.StringRes + +class NameValidator( + @StringRes errorMessage: Int +) : SimpleValidator( + condition = { name -> + NAME_PATTERN.matches(name) && !MISMATCH_PATTERN.containsMatchIn(name) + }, + errorMessage = errorMessage +) { + private companion object { + // "\u0020" - пробел + const val BASE_SYMBOLS = "а-яА-ЯёЁ" + + val NAME_PATTERN = "^[$BASE_SYMBOLS][\\u0020\\-$BASE_SYMBOLS]*[$BASE_SYMBOLS]\$".toRegex() + + // TODO придумать как включить всё это в одну регулярку + // проверка на пробелы и дефисы + val MISMATCH_PATTERN = "\\u0020\\u0020|\\u0020-|-\\u0020|--".toRegex() + } +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/NumberValidator.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/NumberValidator.kt new file mode 100644 index 0000000..448e873 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/NumberValidator.kt @@ -0,0 +1,14 @@ +package ru.touchin.roboswag.views.input.validatable.validator.implementation + +import androidx.annotation.StringRes + +class NumberValidator( + @StringRes errorMessage: Int +) : RegexValidator( + regex = NUMBER_PATTERN.toRegex(), + errorMessage = errorMessage +) { + private companion object { + const val NUMBER_PATTERN = "\\d+" + } +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/PatternValidator.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/PatternValidator.kt new file mode 100644 index 0000000..ce85f35 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/PatternValidator.kt @@ -0,0 +1,12 @@ +package ru.touchin.roboswag.views.input.validatable.validator.implementation + +import androidx.annotation.StringRes +import java.util.regex.Pattern + +open class PatternValidator( + private val pattern: Pattern, + @StringRes val errorMessage: Int +) : SimpleValidator( + condition = { text -> pattern.matcher(text).find() }, + errorMessage = errorMessage +) diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/PhoneValidator.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/PhoneValidator.kt new file mode 100644 index 0000000..9ed7704 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/PhoneValidator.kt @@ -0,0 +1,14 @@ +package ru.touchin.roboswag.views.input.validatable.validator.implementation + +import androidx.annotation.StringRes + +class PhoneValidator( + @StringRes errorMessage: Int +) : RegexValidator( + regex = PHONE_PATTERN.toRegex(), + errorMessage = errorMessage +) { + private companion object { + const val PHONE_PATTERN = "\\d{11}" + } +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/PostDateValidator.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/PostDateValidator.kt new file mode 100644 index 0000000..4ebb1e3 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/PostDateValidator.kt @@ -0,0 +1,22 @@ +package ru.touchin.roboswag.views.input.validatable.validator.implementation + +import androidx.annotation.StringRes +import org.joda.time.DateTime + +class PostDateValidator( + @StringRes incorrectDateRes: Int, + @StringRes incorrectMaxDateRes: Int, + @StringRes incorrectMinDateRes: Int, + minDate: Long, + maxDate: Long = DateTime.now().millis +) : ContainerValidator( + DateFormatValidator(incorrectDateRes), + MaxDateValidator( + maxDate = maxDate, + errorMessage = incorrectMaxDateRes + ), + MinDateValidator( + minDate = minDate, + errorMessage = incorrectMinDateRes + ) +) diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/PreDateFormatValidator.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/PreDateFormatValidator.kt new file mode 100644 index 0000000..8ceec1c --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/PreDateFormatValidator.kt @@ -0,0 +1,9 @@ +package ru.touchin.roboswag.views.input.validatable.validator.implementation + +import androidx.annotation.StringRes +import ru.touchin.roboswag.views.input.utils.DateValidationUtils + +class PreDateFormatValidator(@StringRes errorMessage: Int) : SimpleValidator( + condition = { date -> DateValidationUtils.isDatePreValid(date) }, + errorMessage = errorMessage +) diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/RegexValidator.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/RegexValidator.kt new file mode 100644 index 0000000..13837eb --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/RegexValidator.kt @@ -0,0 +1,13 @@ +package ru.touchin.roboswag.views.input.validatable.validator.implementation + +import androidx.annotation.StringRes + +open class RegexValidator( + private val regex: Regex, + @StringRes val errorMessage: Int +) : SimpleValidator( + condition = { text -> regex.matches(text) }, + errorMessage = errorMessage +) { + constructor(regex: String, @StringRes errorMessage: Int) : this(regex.toRegex(), errorMessage) +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/SimpleValidator.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/SimpleValidator.kt new file mode 100644 index 0000000..3af59e4 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/SimpleValidator.kt @@ -0,0 +1,16 @@ +package ru.touchin.roboswag.views.input.validatable.validator.implementation + +import androidx.annotation.StringRes +import ru.touchin.roboswag.views.input.validatable.validator.ValidateResult + +open class SimpleValidator( + private val condition: (String) -> Boolean, + @StringRes private val errorMessage: Int +) : BasePredicateValidator( + predicate = { text -> + when (condition.invoke(text)) { + true -> ValidateResult.Success(text) + false -> ValidateResult.Error(text, errorMessage) + } + } +) diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/TimeValidator.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/TimeValidator.kt new file mode 100644 index 0000000..1e7f396 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/TimeValidator.kt @@ -0,0 +1,14 @@ +package ru.touchin.roboswag.views.input.validatable.validator.implementation + +import androidx.annotation.StringRes + +class TimeValidator( + @StringRes errorMessage: Int +) : RegexValidator( + regex = TIME_PATTERN.toRegex(), + errorMessage = errorMessage +) { + private companion object { + const val TIME_PATTERN = "^([0-1]?[0-9]|2[0-3]):[0-5][0-9]\$" + } +} diff --git a/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/UpperCaseValidator.kt b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/UpperCaseValidator.kt new file mode 100644 index 0000000..0b67205 --- /dev/null +++ b/views/src/main/java/ru/touchin/roboswag/views/input/validatable/validator/implementation/UpperCaseValidator.kt @@ -0,0 +1,10 @@ +package ru.touchin.roboswag.views.input.validatable.validator.implementation + +import androidx.annotation.StringRes + +class UpperCaseValidator( + @StringRes errorMessage: Int +) : CharValidator( + condition = { char -> char.isLetter() && char.isUpperCase() }, + errorMessage = errorMessage +) diff --git a/views/src/main/res/layout/view_base_input_text.xml b/views/src/main/res/layout/view_base_input_text.xml new file mode 100644 index 0000000..2c1fb76 --- /dev/null +++ b/views/src/main/res/layout/view_base_input_text.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/views/src/main/res/values/attrs.xml b/views/src/main/res/values/attrs.xml index 60c6336..8de4e5f 100644 --- a/views/src/main/res/values/attrs.xml +++ b/views/src/main/res/values/attrs.xml @@ -75,4 +75,9 @@ + + + + + diff --git a/views/src/main/res/values/view_base_input_text_attrs.xml b/views/src/main/res/values/view_base_input_text_attrs.xml new file mode 100644 index 0000000..94fd933 --- /dev/null +++ b/views/src/main/res/values/view_base_input_text_attrs.xml @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +