Skip to content

Instantly share code, notes, and snippets.

@ildar2
Created January 27, 2022 09:09
Show Gist options
  • Save ildar2/9586f05fcf1b282f75c363ea4045a602 to your computer and use it in GitHub Desktop.
Save ildar2/9586f05fcf1b282f75c363ea4045a602 to your computer and use it in GitHub Desktop.
Common UI for DisplayDiffAdapter
/**
* Стандартная кнопка
*/
class CommonButtonDisplay(
val textRes: Int,
var enabled: Boolean = true,
val click: ((CommonButtonDisplay) -> Unit)? = null
) : DisplayDiffItem(R.layout.common_item_button, textRes.toString(), enabled.toString()) {
class ViewHolder(view: View) : DisplayViewHolder<CommonButtonDisplay>(view) {
override fun bind(item: CommonButtonDisplay) {
button.setText(item.textRes)
button.isEnabled = item.enabled
button.setOnClickListener {
item.click?.invoke(item)
}
}
}
}
/**
* Стандартный элемент для отображения информации
*/
class CommonInfoDisplay(
val textRes: Int,
vararg val args: Any,
val gravity: Int? = null,
val textSize: Float? = null
) : DisplayDiffItem(R.layout.common_item_info, textRes.toString(), "") {
class ViewHolder(view: View) : DisplayViewHolder<CommonInfoDisplay>(view) {
override fun bind(item: CommonInfoDisplay) {
if (item.gravity == Gravity.START) {
tv_info.gravity = Gravity.START
} else {
tv_info.gravity = Gravity.CENTER
}
tv_info.setText(item.textRes)
item.textSize?.let {
tv_info.setTextSize(TypedValue.COMPLEX_UNIT_SP, it)
}
tv_info.text = tv_info.resources.getString(item.textRes, *item.args)
}
}
}
/**
* Стандартное поле ввода текста
*/
class CommonInputDisplay(
val name: String,
var value: String? = null,
var enabled: Boolean = true,
val filter: (FilterConfig.() -> Unit)? = null,
val mask: String? = null,
val inputMode: InputMode = InputMode.TEXT,
val shouldUpdate: Boolean = false,
var error: String? = null,
val onInput: ((String) -> Unit)? = null
) : DisplayDiffItem(R.layout.common_item_input, name, shouldUpdate.toString()) {
class ViewHolder(view: View) : DisplayViewHolder<CommonInputDisplay>(view) {
private val watcher = object : SimpleTextWatcher {
override fun afterTextChanged(s: Editable?) {
action(s?.toString().orEmpty())
}
}
private var action: (String) -> Unit = {}
private var maskListener: MaskedTextChangedListener? = null
override fun bind(item: CommonInputDisplay) {
input_layout.hint = item.name
input_layout.isEnabled = item.enabled
input_layout.isErrorEnabled = !item.error.isNullOrBlank()
input_layout.error = item.error
item.filter?.let { config ->
input_et.setFilters {
config.invoke(this)
}
} ?: run {
input_et.filters = arrayOf()
}
input_et.removeTextChangedListener(watcher)
input_et.inputType = item.inputMode.value
item.mask?.let { mask ->
maskListener = MaskedTextChangedListener.installOn(
input_et, mask,
object : MaskedTextChangedListener.ValueListener {
override fun onTextChanged(
maskFilled: Boolean,
extractedValue: String,
formattedValue: String
) {
item.value = extractedValue
item.itemDisplayValue = extractedValue
input_layout.error = null
input_layout.isErrorEnabled = false
item.onInput?.invoke(extractedValue)
}
})
/**
* Смена inputType с Text на Phone нужна из-за того что на некоторых клавиатурах
* происходит задвоение цифр при наборе номера телефона, если inputType=text. Проблема описана на гитхабе:
* https://github.com/RedMadRobot/input-mask-android/issues/108
*/
when (mask) {
Constants.PHONE_MASK -> {
input_et.inputType = InputType.TYPE_CLASS_PHONE
val number = item.value.orEmpty()
maskListener?.setText(
if (number.length == Constants.PHONE_LENGTH) Constants.PHONE_PREFIX + number else number,
true
)
}
else -> {
input_et.setText(item.value)
}
}
} ?: run {
input_et.removeTextChangedListener(maskListener)
maskListener = null
input_et.setText(item.value)
action = { text ->
item.value = text
item.itemDisplayValue = text
input_layout.error = null
input_layout.isErrorEnabled = false
item.onInput?.invoke(text)
}
input_et.addTextChangedListener(watcher)
}
}
override fun bind(item: CommonInputDisplay, payloads: MutableList<Any>) {
if (payloads.firstOrNull() != DIFF_SKIP_UPDATE) bind(item)
}
override fun unbind() {
itemView.hideKeyboard()
}
}
/**
* Тип клавиатуры при вводе
* @see InputType
*/
enum class InputMode(
val value: Int
) {
TEXT(InputType.TYPE_CLASS_TEXT),
NUMBERS(InputType.TYPE_CLASS_NUMBER),
PHONE(InputType.TYPE_CLASS_PHONE),
PASSWORD(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD),
}
}
/**
* Класс-обертка для событий, которые должны отрабатывать только один раз
* Необходимо оборачивать [LiveData]-события для [Toast] и для навигации в этот класс, чтобы избежать повторных вызовов
*
* Used as a wrapper for data that is exposed via a LiveData that represents an event
* got from https://medium.com/androiddevelopers/livedata-with-snackbar-navigation-and-other-events-the-singleliveevent-case-ac2622673150
*/
open class EventWrapper<out T>(private val content: T) {
var hasBeenHandled = false
private set
/**
* Returns the content and prevents its use again.
*/
fun get(): T? = if (hasBeenHandled) {
null
} else {
hasBeenHandled = true
content
}
/**
* Returns the content, even if it's already been handled.
*/
fun peek(): T = content
}
/**
* Can be used for no-data events
*/
object VoidEvent {
val WRAPPED: EventWrapper<VoidEvent>
get() = EventWrapper(VoidEvent)
}
/**
* An [Observer] for [EventWrapper]s, simplifying the pattern of checking if the [EventWrapper]'s content has
* already been handled.
*
* [onEventUnhandledContent] is *only* called if the [EventWrapper]'s contents has not been handled.
*/
class EventObserver<T>(
private val onEventUnhandledContent: (T) -> Unit
) : Observer<EventWrapper<T>> {
override fun onChanged(event: EventWrapper<T>?) {
event?.get()?.let(onEventUnhandledContent)
}
}
fun <T : Any> T.wrapEvent() = EventWrapper(this)
import android.text.InputFilter
import android.text.LoginFilter
import android.widget.EditText
/**
* Выставляем фильтр ввода для [EditText]
* можно сделать всё капсом, выставить [FilterConfig.maxLength], запретить кириллицу, цифры и тд
* @see FilterConfig
*/
fun EditText.setFilters(
filterConfig: FilterConfig.() -> Unit
) {
val array = mutableListOf<InputFilter>()
val filter = FilterConfig().apply(filterConfig)
filter.maxLength?.let { array.add(InputFilter.LengthFilter(it)) }
if (filter.allCaps) array.add(InputFilter.AllCaps())
if (filter.enabled) array.add(CustomInputFilter(filter))
filters = array.toTypedArray()
}
class FilterConfig(
var allowChars: Boolean = true,
var allowNumbers: Boolean = true,
var allCaps: Boolean = false,
var allowCyrillic: Boolean = false,
var allowDecimal: Boolean = false,
var maxLength: Int? = null,
var enabled: Boolean = true
) {
companion object Presets {
val LATIN_NUMBERS: FilterConfig.() -> Unit = { /* default */ }
val NUMBERS: FilterConfig.() -> Unit = { allowChars = false }
val DECIMAL: FilterConfig.() -> Unit = {
allowChars = false
allowDecimal = true
}
/**
* Для ввода имени владельца карты
*/
val CAPS_LATIN: FilterConfig.() -> Unit = {
allowNumbers = false
allCaps = true
}
/**
* Для ввода номера счёта
*/
val CAPS_LATIN_NUMBERS: FilterConfig.() -> Unit = {
allCaps = true
}
}
}
private class CustomInputFilter(
private val filterType: FilterConfig,
appendInvalid: Boolean = false
) : LoginFilter.UsernameFilterGeneric(appendInvalid) {
override fun isAllowed(c: Char): Boolean = when {
c in '0'..'9' && filterType.allowNumbers -> true
c in 'a'..'z' && filterType.allowChars -> true
c in 'A'..'Z' && filterType.allowChars -> true
c in 'а'..'я' && filterType.allowCyrillic -> true
c in 'А'..'Я' && filterType.allowCyrillic -> true
c == '.' && filterType.allowDecimal -> true
else -> false
}
}
class MainAuthViewModel(
private val repository: AuthRepository,
private val prefManager: PrefManager,
) : BaseViewModel(), CommonItemViewModel<AuthActionData> {
override val itemsLiveData = MutableLiveData<List<DisplayDiffItem>>()
override val actionLiveData = MutableLiveData<EventWrapper<AuthActionData>>()
val certErrorsLiveData = MutableLiveData<EventWrapper<Int>>()
/**
* Номер телефона
* в формате 0004567890
*/
var phone = PhoneNumber(prefManager.getPhone())
/**
* ИИН
*/
var taxCode = ""//prefManager.getTaxCode()
/**
* Файл ЭЦП
*/
private var pickedFile: Uri? = null
private var fileName: String? = null
/**
* Пароль от ЭЦП
*/
private var pass = ""
/**
* Статус депозитора
*
* у ЦОИД нет лиц нерезидентов РК, поэтому лайвнес для них пропускаем
*/
private var isResident = prefManager.getResidency()
/**
* Элементы, которые нужно валидировать
*/
private val elementValidityMap: HashMap<Int, Boolean> = hashMapOf(
R.string.auth_phone_hint to phone.isValid(),
R.string.auth_login_hint to taxCode.isValidTaxCode(),
R.string.auth_cert_hint to false,
R.string.auth_pass_hint to false,
)
init {
loadItems()
}
override fun loadItems(update: Boolean) {
itemsLiveData.value = listOf(
CommonInfoDisplay(R.string.auth_header_desc, gravity = Gravity.START),
AuthResidentDisplay(isResident) { residency ->
changeResidency(residency)
},
CommonInputDisplay(
getLocz().static(R.string.auth_phone_hint),
phone.value,
mask = Constants.PHONE_MASK,
inputMode = CommonInputDisplay.InputMode.PHONE,
shouldUpdate = update,
) { s ->
phone = PhoneNumber(s)
elementValidityMap[R.string.auth_phone_hint] = phone.isValid()
loadItems()
},
CommonInputDisplay(
getLocz().static(R.string.auth_login_hint),
taxCode,
inputMode = CommonInputDisplay.InputMode.NUMBERS,
filter = {
apply(FilterConfig.NUMBERS)
maxLength = 12
},
shouldUpdate = update,
) { s ->
taxCode = s
elementValidityMap[R.string.auth_login_hint] = s.isValidTaxCode()
loadItems()
},
CommonInfoButtonDisplay(
fileName ?: getLocz().static(R.string.auth_cert_hint),
pickedFile != null,
R.drawable.ic_key,
) {
pickCert()
},
CommonInputDisplay(
getLocz().static(R.string.auth_pass_hint),
pass,
inputMode = CommonInputDisplay.InputMode.PASSWORD,
shouldUpdate = update,
) { s ->
pass = s
elementValidityMap[R.string.auth_pass_hint] = s.isNotEmpty()
loadItems()
},
CommonButtonDisplay(R.string.auth_button, enabled = validateButton()) {
login()
},
CommonLinkDisplay(R.string.auth_info_ecp) {
openEdsInfo()
},
)
}
private fun pickCert() {
actionLiveData.value = AuthActionData(
AuthActionType.PICK_CERT
).wrapEvent()
}
private fun proceed() {
actionLiveData.value = AuthActionData(
if (isResident) AuthActionType.SUCCESS_LIVENESS else AuthActionType.SUCCESS_BMG
).wrapEvent()
}
private fun openEdsInfo() {
actionLiveData.value = AuthActionData(
AuthActionType.OPEN_EDS_INFO
).wrapEvent()
}
private fun changeResidency(isResident: Boolean) {
this.isResident = isResident
prefManager.saveResidency(isResident)
}
private fun validateButton() = !elementValidityMap.containsValue(false)
&& statusLiveData.value != Status.SHOW_LOADING
private fun login() {
if (pickedFile == null) {
setError(getLocz().static(R.string.auth_cert_hint))
return
}
makeRequest({
prefManager.savePhone(phone.value)
prefManager.saveTaxCode(taxCode)
repository.loginWithEds(phone, taxCode, pickedFile!!, pass)
}) {
when (it) {
is RequestResult.Success -> proceed()
is RequestResult.Error -> {
if (it.code in AuthRepository.CERT_ERROR_PASSWORD..AuthRepository.CERT_ERROR_INVALID) {
certErrorsLiveData.value = it.code.wrapEvent()
} else {
setError(it.error)
}
Analytics.reportError(EDS_ERROR, it.error)
}
}
}
}
fun setPickedFile(file: Uri, fileName: String?) {
pickedFile = file
this.fileName = fileName
elementValidityMap[R.string.auth_cert_hint] = fileName != null
loadItems()
}
fun setDebugData() {
if (RuntimeBehavior.isFeatureEnabled(FeatureFlag.DEBUG_AUTOFILL)) {
phone = PhoneNumber(prefManager.getAutoFillValue(AutofillValue.DEBUG_PHONE))
elementValidityMap[R.string.auth_phone_hint] = true
taxCode = prefManager.getAutoFillValue(AutofillValue.DEBUG_TAX_CODE)
elementValidityMap[R.string.auth_login_hint] = true
pass = prefManager.getAutoFillValue(AutofillValue.DEBUG_PASS)
elementValidityMap[R.string.auth_pass_hint] = true
loadItems(true)
}
}
}
class AuthActionData(val type: AuthActionType, val data: Bundle? = null)
enum class AuthActionType {
PICK_CERT, SUCCESS_LIVENESS, SUCCESS_BMG, OPEN_EDS_INFO
}
/**
* inline class [PhoneNumber] служит оберткой для номера телефона.
* функция [formatNumber] для форматирования, при выводе номера на экран по дизайну
*/
@JvmInline
value class PhoneNumber(
val value: String
) {
fun formatNumber(): String = format(
"+" + Constants.PHONE_PREFIX + " (%s) %s %s",
value.substring(0, 3),
value.substring(3, 6),
value.substring(6)
)
val length: Int
get() = value.length
fun isValid(): Boolean = length == Constants.PHONE_LENGTH
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment