feature: add BaseTextInputView from MIR #4

Open
bogdan.terehov wants to merge 2 commits from feature/base_input into master
68 changed files with 2662 additions and 0 deletions

View File

@ -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") {

View File

@ -0,0 +1,5 @@
package ru.touchin.roboswag.views.input
import android.view.MotionEvent
typealias TouchListener = (event: MotionEvent) -> Unit

View File

@ -0,0 +1,9 @@
package ru.touchin.roboswag.views.input
open class BaseListenersContainer<E>(protected val listeners: MutableList<E> = mutableListOf()) {
fun add(listener: E): Boolean = listeners.add(listener)
fun remove(listener: E): Boolean = listeners.remove(listener)
}

View File

@ -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
}
}

View File

@ -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<BaseTextInputViewSavedState> {
override fun createFromParcel(parcel: Parcel): BaseTextInputViewSavedState = BaseTextInputViewSavedState(parcel)
override fun newArray(size: Int): Array<BaseTextInputViewSavedState?> = arrayOfNulls(size)
}
}
data class Data(
val text: String = "",
val supportHintText: String? = "",
val bottomHintState: BottomHintState = BottomHintState.INFORMATION
)
}

View File

@ -0,0 +1,17 @@
package ru.touchin.roboswag.views.input
import android.view.View
class FocusChangeListenerContainer : BaseListenersContainer<View.OnFocusChangeListener>(), 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) }
)
}

View File

@ -0,0 +1,6 @@
package ru.touchin.roboswag.views.input
import android.text.TextWatcher
import android.view.View
interface GlobalEditTextEventListener : View.OnFocusChangeListener, TextWatcher

View File

@ -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<Int> {
val indexDelimiterList = mutableListOf<Int>()
hint.forEachIndexed { index, char ->
if (char == '.') indexDelimiterList.add(index)
}
return indexDelimiterList
}
}

View File

@ -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
}
}

View File

@ -0,0 +1,17 @@
package ru.touchin.roboswag.views.input
import android.text.Editable
import android.text.TextWatcher
class TextWatcherContainer : BaseListenersContainer<TextWatcher>(), 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) }
}

View File

@ -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<TouchListener>(), 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
}

View File

@ -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()
}
}
}
}

View File

@ -0,0 +1,7 @@
package ru.touchin.roboswag.views.input.delegates.bottom_hint
enum class BottomHintState {
ERROR,
INFORMATION,
EMPTY
}

View File

@ -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?)
}

View File

@ -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()
}
}
}

View File

@ -0,0 +1,6 @@
package ru.touchin.roboswag.views.input.delegates.bottom_hint
enum class ShowSupportHintMode {
PERMANENT,
ON_EMPTY
}

View File

@ -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
}

View File

@ -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
}

View File

@ -0,0 +1,6 @@
package ru.touchin.roboswag.views.input.delegates.edit_text
interface MaskFilledListener {
fun onFilled(maskFilled: Boolean, extractedText: String)
}

View File

@ -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)
}
}

View File

@ -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 {
/**
* <pre>
* |-------|-------|-------|-------|
* 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
* |-------|-------|-------|-------|</pre>
*/
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
}

View File

@ -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
}
}

View File

@ -0,0 +1,13 @@
package ru.touchin.roboswag.views.input.delegates.validators
enum class ValidatorTypes {
NONE,
PHONE,
DATE,
TIME,
EMPTY,
EMAIL,
LATIN,
CYRILLIC,
NUMBER
}

View File

@ -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
}
}
}

View File

@ -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<String?> {
val emailParts = arrayOfNulls<String>(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)
}
}

View File

@ -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]"
}

View File

@ -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()

View File

@ -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>(), ValidatorListener {
override fun onValidate(result: ValidateResult) =
listeners.forEach { validatorListener -> validatorListener.onValidate(result) }
}

View File

@ -0,0 +1,3 @@
package ru.touchin.roboswag.views.input.validatable
interface ValidatorUnit

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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)
}
}
}

View File

@ -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()
}
}

View File

@ -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
}

View File

@ -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()
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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<ValidatorListener>? = 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)
}
}

View File

@ -0,0 +1,7 @@
package ru.touchin.roboswag.views.input.validatable.validator
interface Validatable {
fun validate(text: String): ValidateResult
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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) }
}

View File

@ -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)
}
}
)

View File

@ -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<ValidatorUnit>
) : 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
}
}

View File

@ -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 = "^[а-яА-Я]+\$"
}
}

View File

@ -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]"
}
}

View File

@ -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
)

View File

@ -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
)

View File

@ -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
)

View File

@ -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]+\$"
}
}

View File

@ -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
)

View File

@ -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
)

View File

@ -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
)

View File

@ -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
)

View File

@ -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()
}
}

View File

@ -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+"
}
}

View File

@ -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
)

View File

@ -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}"
}
}

View File

@ -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
)
)

View File

@ -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
)

View File

@ -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)
}

View File

@ -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)
}
}
)

View File

@ -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]\$"
}
}

View File

@ -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
)

View File

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<merge
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:parentTag="android.widget.LinearLayout">
<com.google.android.material.textfield.TextInputLayout
android:tag="text_input_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:errorEnabled="false"
tools:hint="Hint"
tools:textColorHint="@android:color/darker_gray">
<ru.touchin.roboswag.views.input.MaskedHintEditText
android:tag="edit_text"
android:textColorHighlight="@android:color/holo_green_dark"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:focusableInTouchMode="true"
android:background="@null"
android:paddingTop="10dp"
android:paddingHorizontal="4dp"
android:paddingBottom="4dp"
tools:text="Text" />
<!--TODO add android:textAppearance to styles-->
<View
android:tag="underline"
android:layout_width="wrap_content"
android:layout_height="0.5dp"
android:layout_marginHorizontal="4dp"
android:background="@android:color/darker_gray" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginHorizontal="4dp"
android:orientation="horizontal">
<ImageView
android:tag="support_hint_start_image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_margin="4dp"
android:visibility="gone"
tools:visibility="visible"
tools:src="@android:drawable/btn_plus"
tools:tint="@android:color/darker_gray" />
<TextView
android:tag="support_hint"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_gravity="center"
tools:text="Bottom hint Bottom hint Bottom hint\nBottom hint" />
</LinearLayout>
</com.google.android.material.textfield.TextInputLayout>
</merge>

View File

@ -75,4 +75,9 @@
<attr name="isUnderlineText"/>
</declare-styleable>
<declare-styleable name="MaskedHintEditText">
<attr name="maskedHint" format="string" />
<attr name="maskedHintColor" format="color" />
</declare-styleable>
</resources>

View File

@ -0,0 +1,138 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="BaseTextInputView">
<attr name="validator">
<enum name="none" value="0" />
<enum name="phone" value="1" />
<enum name="date" value="2" />
<enum name="time" value="3" />
<enum name="empty" value="4" />
<enum name="email" value="5" />
<enum name="latin" value="6" />
<enum name="cyrillic" value="7" />
<enum name="number" value="8" />
</attr>
<attr name="validatorMode">
<flag name="none" value="0" />
<flag name="hasFocus" value="1" />
<flag name="lossFocus" value="2" />
<flag name="beforeTextChanged" value="4" />
<flag name="onTextChanged" value="8" />
<flag name="afterTextChanged" value="16" />
</attr>
<attr name="mask">
<enum name="none" value="0" />
<enum name="phone" value="1" />
<enum name="date" value="2" />
<enum name="bankCard" value="3" />
<enum name="mid" value="4" />
</attr>
<attr name="informationHint" format="string" />
<attr name="showInformationHintMode">
<enum name="permanent" value="0" />
<enum name="onEmpty" value="1" />
</attr>
<attr name="floatError" format="boolean" />
<!--Аттрибуты TextInputEditText-->
<attr name="errorEnabled" format="boolean" />
<attr name="error" format="string" />
<attr name="errorTextAppearance" format="reference" />
<attr name="errorTextColor" format="reference|color" />
<attr name="hint" format="string" />
<attr name="hintTextAppearance" format="reference" />
<attr name="hintTextColor" format="reference|color" />
<attr name="informationHintTextColor" format="reference|color" />
<!--Аттрибуты доставшиеся по "наследству"-->
<attr name="editTextMaskedHint" format="string" />
<attr name="editTextMaskedHintColor" format="color" />
<attr name="editTextMargin" format="dimension" />
<attr name="editTextMarginTop" format="dimension" />
<attr name="editTextMarginBottom" format="dimension" />
<attr name="editTextMarginStart" format="dimension" />
<attr name="editTextMarginEnd" format="dimension" />
<attr name="editTextPadding" format="dimension" />
<attr name="editTextPaddingTop" format="dimension" />
<attr name="editTextPaddingBottom" format="dimension" />
<attr name="editTextPaddingStart" format="dimension" />
<attr name="editTextPaddingEnd" format="dimension" />
<attr name="imeOptions">
<flag name="normal" value="0x00000000" />
<flag name="actionUnspecified" value="0x00000000" />
<flag name="actionNone" value="0x00000001" />
<flag name="actionGo" value="0x00000002" />
<flag name="actionSearch" value="0x00000003" />
<flag name="actionSend" value="0x00000004" />
<flag name="actionNext" value="0x00000005" />
<flag name="actionDone" value="0x00000006" />
<flag name="actionPrevious" value="0x00000007" />
<flag name="flagNoPersonalizedLearning" value="0x1000000" />
<flag name="flagNoFullscreen" value="0x2000000" />
<flag name="flagNavigatePrevious" value="0x4000000" />
<flag name="flagNavigateNext" value="0x8000000" />
<flag name="flagNoExtractUi" value="0x10000000" />
<flag name="flagNoAccessoryAction" value="0x20000000" />
<flag name="flagNoEnterAction" value="0x40000000" />
<flag name="flagForceAscii" value="0x80000000" />
</attr>
<attr name="inputType">
<flag name="none" value="0x00000000" />
<flag name="text" value="0x00000001" />
<flag name="textCapCharacters" value="0x00001001" />
<flag name="textCapWords" value="0x00002001" />
<flag name="textCapSentences" value="0x00004001" />
<flag name="textAutoCorrect" value="0x00008001" />
<flag name="textAutoComplete" value="0x00010001" />
<flag name="textMultiLine" value="0x00020001" />
<flag name="textImeMultiLine" value="0x00040001" />
<flag name="textNoSuggestions" value="0x00080001" />
<flag name="textUri" value="0x00000011" />
<flag name="textEmailAddress" value="0x00000021" />
<flag name="textEmailSubject" value="0x00000031" />
<flag name="textShortMessage" value="0x00000041" />
<flag name="textLongMessage" value="0x00000051" />
<flag name="textPersonName" value="0x00000061" />
<flag name="textPostalAddress" value="0x00000071" />
<flag name="textPassword" value="0x00000081" />
<flag name="textVisiblePassword" value="0x00000091" />
<flag name="textWebEditText" value="0x000000a1" />
<flag name="textFilter" value="0x000000b1" />
<flag name="textPhonetic" value="0x000000c1" />
<flag name="textWebEmailAddress" value="0x000000d1" />
<flag name="textWebPassword" value="0x000000e1" />
<flag name="number" value="0x00000002" />
<flag name="numberSigned" value="0x00001002" />
<flag name="numberDecimal" value="0x00002002" />
<flag name="numberPassword" value="0x00000012" />
<flag name="phone" value="0x00000003" />
<flag name="datetime" value="0x00000004" />
<flag name="date" value="0x00000014" />
<flag name="time" value="0x00000024" />
</attr>
<attr name="digits" format="string" />
<attr name="drawableEnd" format="reference|color" />
<attr name="editTextFocusable" format="boolean" />
<attr name="editTextFocusableInTouchMode" format="boolean" />
<attr name="editTextLongClickable" format="boolean" />
<attr name="editText" format="string" localization="suggested" />
<attr name="editTextColor" format="reference|color" />
<attr name="editTextAppearance" format="reference" />
<attr name="maxLength" format="integer" min="0" />
<attr name="maxLines" format="integer" min="0" />
<attr name="editTextBackgroundTint" format="reference|color" />
</declare-styleable>
</resources>