From 25f46559f59100bdc77b9ddf505fedc7bcca00c4 Mon Sep 17 00:00:00 2001 From: "i.khafizov" Date: Tue, 1 Nov 2022 12:30:07 +0400 Subject: [PATCH 001/126] MC-7034 add smartfield view --- ui/src/main/AndroidManifest.xml | 1 + .../acquiring/sdk/smartfield/AcqEditText.kt | 240 ++++++ .../sdk/smartfield/AcqTextFieldView.kt | 296 +++++++ .../sdk/smartfield/AcqTextInputLayout.kt | 758 ++++++++++++++++++ .../sdk/smartfield/BaubleClearButton.kt | 32 + .../tinkoff/acquiring/sdk/utils/HapticUtil.kt | 38 + .../acquiring/sdk/utils/SimpleTextWatcher.kt | 46 ++ .../acquiring/sdk/utils/ViewExtUtil.kt | 123 +++ ui/src/main/res/drawable/acq_ic_clear.xml | 14 + ui/src/main/res/drawable/acq_sf_cursor.xml | 8 + .../res/drawable/acq_sf_hint_selector.xml | 6 + .../drawable/acq_sf_primary_text_selector.xml | 8 + .../res/drawable/acq_sf_text_field_bg.xml | 19 + .../drawable/acq_sf_title_text_selector.xml | 8 + .../main/res/layout/acq_layout_text_field.xml | 42 + ui/src/main/res/values-night/colors.xml | 5 +- ui/src/main/res/values-ru/strings.xml | 13 + ui/src/main/res/values/attrs.xml | 84 ++ ui/src/main/res/values/colors.xml | 5 + ui/src/main/res/values/dimens.xml | 8 + ui/src/main/res/values/strings.xml | 9 + ui/src/main/res/values/styles.xml | 21 + 22 files changed, 1783 insertions(+), 1 deletion(-) create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/smartfield/AcqEditText.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/smartfield/AcqTextFieldView.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/smartfield/AcqTextInputLayout.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/smartfield/BaubleClearButton.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/HapticUtil.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/SimpleTextWatcher.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/ViewExtUtil.kt create mode 100644 ui/src/main/res/drawable/acq_ic_clear.xml create mode 100644 ui/src/main/res/drawable/acq_sf_cursor.xml create mode 100644 ui/src/main/res/drawable/acq_sf_hint_selector.xml create mode 100644 ui/src/main/res/drawable/acq_sf_primary_text_selector.xml create mode 100644 ui/src/main/res/drawable/acq_sf_text_field_bg.xml create mode 100644 ui/src/main/res/drawable/acq_sf_title_text_selector.xml create mode 100644 ui/src/main/res/layout/acq_layout_text_field.xml diff --git a/ui/src/main/AndroidManifest.xml b/ui/src/main/AndroidManifest.xml index e2e38bd6..5f74bd15 100644 --- a/ui/src/main/AndroidManifest.xml +++ b/ui/src/main/AndroidManifest.xml @@ -32,6 +32,7 @@ + diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/smartfield/AcqEditText.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/smartfield/AcqEditText.kt new file mode 100644 index 00000000..653edff7 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/smartfield/AcqEditText.kt @@ -0,0 +1,240 @@ +package ru.tinkoff.acquiring.sdk.smartfield + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.os.Build +import android.text.InputFilter +import android.text.Spanned +import android.util.AttributeSet +import android.util.TypedValue +import android.view.KeyEvent +import android.view.WindowInsets +import android.view.inputmethod.InputMethodManager +import androidx.appcompat.widget.AppCompatEditText +import androidx.core.content.res.ResourcesCompat +import androidx.core.view.updatePadding +import ru.tinkoff.acquiring.sdk.R +import ru.tinkoff.acquiring.sdk.utils.HapticUtil +import kotlin.math.roundToInt + +/** + * @author Ilnar Khafizov + */ +internal class AcqEditText +@JvmOverloads +constructor( + context: Context, + attrs: AttributeSet? = null +) : AppCompatEditText(context, attrs) { + + var errorHighlighted = false + set(value) { + field = value + refreshDrawableState() + } + var pseudoFocused = false + set(value) { + field = value + refreshDrawableState() + } + + var keyboardBackPressedListener: (() -> Unit)? = null + + var appendix: String? = null + set(value) { + if (field == value) return + field = value + invalidateAppendix() + } + + var appendixSpace: Float = 0f + set(value) { + field = value + invalidateAppendix() + } + + var appendixColorRes: Int = -1 + set(value) { + field = value + appendixColor = value.takeIf { it != -1 }?.let { + ResourcesCompat.getColorStateList(context.resources, it, context.theme) + } + } + private var appendixColor: ColorStateList? = null + set(value) { + field = value + invalidateAppendix() + } + + var appendixSide: Int = ZAppendixSide.RIGHT + set(value) { + field = value + invalidateAppendix() + } + + var maxSymbols = -1 + set(value) { + field = value + if (field > 0 && text?.length ?: 0 > field) { + text = text + } + } + + private val fontMetrics = Paint.FontMetrics() + + private val lengthFilter = object : InputFilter { + override fun filter(source: CharSequence, start: Int, end: Int, dest: Spanned, dstart: Int, dend: Int): CharSequence? { + if (maxSymbols <= 0) return null + var keep = maxSymbols - (dest.length - (dend - dstart)) + return when { + keep <= 0 -> "" + keep >= end - start -> null + else -> { + keep += start + if (Character.isHighSurrogate(source[keep - 1])) { + --keep + if (keep == start) return "" + } + HapticUtil.performWarningHaptic(context) + source.subSequence(start, keep) + } + } + } + } + + var focusAllower: FocusAllower? = null + + init { + appendixSpace = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, + APPENDIX_SPACE_DEFAULT, context.resources.displayMetrics) + appendixColorRes = appendixColorRes + + filters += lengthFilter + } + + override fun onCreateDrawableState(extraSpace: Int): IntArray { + val states = arrayListOf() + if (errorHighlighted) states.add(ERROR_STATE) + if (pseudoFocused) states.add(PSEUDO_FOCUS_STATE) + val state = super.onCreateDrawableState(extraSpace + states.size) + mergeDrawableStates(state, states.toIntArray()) + return state + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + drawAppendix(canvas) + } + + private fun invalidateAppendix() { + if (appendix.isNullOrEmpty()) { + updatePadding(left = 0, right = 0) + } else { + paint.getFontMetrics(fontMetrics) + val offset = (paint.measureText(appendix) + appendixSpace).roundToInt() + when (appendixSide) { + ZAppendixSide.LEFT -> updatePadding(left = offset, right = 0) + ZAppendixSide.RIGHT -> updatePadding(left = 0, right = offset) + } + } + invalidate() + } + + private fun drawAppendix(canvas: Canvas) { + val appendix = appendix + // nothing to draw + if (appendix.isNullOrEmpty()) return + // self hint is drawn + if (text.isNullOrEmpty() && !hint.isNullOrEmpty()) return + + paint.getFontMetrics(fontMetrics) + + val prevColor = paint.color + paint.color = appendixColor?.getColorForState(drawableState, prevColor) ?: prevColor + + val appendixOffset = when (appendixSide) { + ZAppendixSide.RIGHT -> paddingLeft + paint.measureText(text.toString()) + appendixSpace + else -> 0f + } + canvas.drawText(appendix, appendixOffset, paddingTop - fontMetrics.top, paint) + + paint.color = prevColor + } + + fun getCursorTop(): Int? { + val layout = layout ?: return null + val line = layout.getLineForOffset(selectionEnd) + var lineTop = layout.getLineTop(line) + + lineTop += extendedPaddingTop - scrollY + + return lineTop + } + + override fun onKeyPreIme(keyCode: Int, event: KeyEvent?): Boolean { + if (keyCode == KeyEvent.KEYCODE_BACK && event?.action == KeyEvent.ACTION_UP) { + keyboardBackPressedListener?.invoke() + } + return super.onKeyPreIme(keyCode, event) + } + + private val showKeyboardRunnable = Runnable { if (showSoftInputOnFocus && isFocused) showKeyboard() } + + override fun requestFocus(direction: Int, previouslyFocusedRect: Rect?): Boolean = when { + textInputLayout()?.textEditable == false -> isFocused + focusAllower?.allowsViewTakeFocus(this) == false -> isFocused + else -> super.requestFocus(direction, previouslyFocusedRect) + } + + override fun onFocusChanged(focused: Boolean, direction: Int, previouslyFocusedRect: Rect?) { + super.onFocusChanged(focused, direction, previouslyFocusedRect) + + removeCallbacks(showKeyboardRunnable) + if (isFocused) { + showKeyboardRunnable.run() + postDelayed(showKeyboardRunnable, SHOW_KEYBOARD_DELAY) + } + } + + fun showKeyboard() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + this.windowInsetsController?.show(WindowInsets.Type.ime()) + } else { + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(this, 0) + } + } + + fun hideKeyboard() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + this.windowInsetsController?.hide(WindowInsets.Type.ime()) + } else { + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(windowToken, 0) + } + } + + fun interface FocusAllower { + fun allowsViewTakeFocus(view: AcqEditText): Boolean + } + + companion object { + + private const val SHOW_KEYBOARD_DELAY = 200L + + private const val APPENDIX_SPACE_DEFAULT = 6f //dp + + private val ERROR_STATE = R.attr.acq_sf_state_error + private val PSEUDO_FOCUS_STATE = R.attr.acq_sf_state_pseudo_focus + + fun AcqEditText.textInputLayout(): AcqTextInputLayout? = parent as? AcqTextInputLayout + } +} + +object ZAppendixSide { + const val LEFT = 0 + const val RIGHT = 1 +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/smartfield/AcqTextFieldView.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/smartfield/AcqTextFieldView.kt new file mode 100644 index 00000000..edcfb9b4 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/smartfield/AcqTextFieldView.kt @@ -0,0 +1,296 @@ +package ru.tinkoff.acquiring.sdk.smartfield + +import android.animation.Animator +import android.animation.ValueAnimator +import android.content.Context +import android.os.Parcel +import android.os.Parcelable +import android.text.Editable +import android.text.method.TransformationMethod +import android.util.AttributeSet +import android.util.SparseArray +import android.view.LayoutInflater +import android.view.View +import android.view.View.MeasureSpec.EXACTLY +import android.view.View.MeasureSpec.UNSPECIFIED +import android.view.View.MeasureSpec.makeMeasureSpec +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.TextView +import ru.tinkoff.acquiring.sdk.R +import ru.tinkoff.acquiring.sdk.utils.SimpleTextWatcher.Companion.afterTextChanged +import ru.tinkoff.acquiring.sdk.utils.forEachChild +import ru.tinkoff.acquiring.sdk.utils.lerp +import ru.tinkoff.acquiring.sdk.utils.setHorizontalPadding +import ru.tinkoff.acquiring.sdk.utils.setVerticalPadding + +/** + * @author Ilnar Khafizov + */ +internal open class AcqTextFieldView +@JvmOverloads +constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + init { + LayoutInflater.from(getContext()).inflate(R.layout.acq_layout_text_field, this) + } + + val textInputLayout: AcqTextInputLayout = findViewById(R.id.text_input_layout) + val editText: AcqEditText = findViewById(R.id.edit_text) + private val symbolCounter = SymbolCounter(findViewById(R.id.symbol_counter)) + + var editable = true + set(value) { + field = value + isEnabled = field + recursiveSetEnabled(field) + } + + var textEditable by textInputLayout::textEditable + var title: CharSequence? by textInputLayout::title + var floatingTitle: Boolean by textInputLayout::floatingTitle + var placeholder: CharSequence? by textInputLayout::placeholder + var appendix: String? by textInputLayout::appendix + var appendixColorRes: Int by textInputLayout::appendixColorRes + var appendixSide: Int by textInputLayout::appendixSide + var textSize: Float by textInputLayout::textSize + var fontFamily: Int by textInputLayout::fontFamily + var textStyle: Int by textInputLayout::textStyle + var inputType: Int by textInputLayout::inputType + var transformationMethod: TransformationMethod? by textInputLayout::transformationMethod + var maxLines: Int by textInputLayout::maxLines + var maxSymbols: Int + get() = textInputLayout.maxSymbols + set(value) { + textInputLayout.maxSymbols = value + symbolCounter.update() + } + + var maxSymbolsCounterVisible = true + set(value) { + if (field == value) return + field = value + symbolCounter.update() + } + + var text: String? by textInputLayout::text + + var errorHighlighted: Boolean by textInputLayout::errorHighlighted + var pseudoFocused: Boolean by textInputLayout::pseudoFocused + var focusAllower: AcqEditText.FocusAllower? by editText::focusAllower + var nextPressedListener: (() -> Unit)? by textInputLayout::nextPressedListener + var textChangedCallback: ((Editable?) -> Unit)? by textInputLayout::textChangedCallback + var focusChangeCallback: ((AcqEditText) -> Unit)? by textInputLayout::focusChangeCallback + var keyboardBackPressedListener: (() -> Unit)? by textInputLayout::keyboardBackPressedListener + + init { + orientation = VERTICAL + setVerticalPadding(context.resources.getDimensionPixelSize(R.dimen.acq_sf_vertical_padding)) + setHorizontalPadding(context.resources.getDimensionPixelSize(R.dimen.acq_sf_horizontal_padding)) + + editText.isSaveFromParentEnabled = false + + textInputLayout.addFocusChangeListener { symbolCounter.update() } + editText.afterTextChanged { symbolCounter.update() } + + initAttrs(attrs) + } + + protected open fun initAttrs(attrs: AttributeSet?) { + if (attrs == null) return + + val a = context.obtainStyledAttributes(attrs, R.styleable.AcqTextFieldView) + editable = a.getBoolean(R.styleable.AcqTextFieldView_acq_editable, editable) + textEditable = a.getBoolean(R.styleable.AcqTextFieldView_acq_textEditable, textEditable) + title = a.getString(R.styleable.AcqTextFieldView_acq_title) ?: title + floatingTitle = a.getBoolean(R.styleable.AcqTextFieldView_acq_floatingTitle, floatingTitle) + placeholder = a.getString(R.styleable.AcqTextFieldView_acq_placeholder) ?: placeholder + appendix = a.getString(R.styleable.AcqTextFieldView_acq_appendix) ?: appendix + appendixColorRes = a.getResourceId(R.styleable.AcqTextFieldView_acq_appendixColorRes, appendixColorRes) + appendixSide = a.getInt(R.styleable.AcqTextFieldView_acq_appendixSide, appendixSide) + textSize = a.getDimension(R.styleable.AcqTextFieldView_acq_textSize, -1f) + .takeIf { it != -1f }?.let { it / context.resources.displayMetrics.scaledDensity } ?: textSize + fontFamily = a.getResourceId(R.styleable.AcqTextFieldView_acq_fontFamily, fontFamily) + textStyle = a.getInt(R.styleable.AcqTextFieldView_acq_textStyle, textStyle) + // android:inputType is superseded by acq_inputType if it's set + inputType = a.getInt(R.styleable.AcqTextFieldView_android_inputType, inputType) + inputType = a.getInt(R.styleable.AcqTextFieldView_acq_inputType, inputType) + maxLines = a.getInt(R.styleable.AcqTextFieldView_acq_maxLines, maxLines) + transformationMethod = editText.transformationMethod + maxSymbols = a.getInt(R.styleable.AcqTextFieldView_acq_maxSymbols, maxSymbols) + maxSymbolsCounterVisible = a.getBoolean(R.styleable.AcqTextFieldView_acq_maxSymbolsCounterVisible, maxSymbolsCounterVisible) + text = a.getString(R.styleable.AcqTextFieldView_acq_text) ?: text + a.recycle() + } + + fun setText(text: CharSequence?, resetCursor: Boolean = false) = textInputLayout.setText(text, resetCursor) + + fun requestViewFocus(): Boolean = textInputLayout.requestViewFocus() + + fun clearViewFocus() = textInputLayout.clearViewFocus() + + fun isViewFocused(): Boolean = textInputLayout.isViewFocused() + + fun setViewClickListener(listener: ((View) -> Unit)?) = + when (listener) { + null -> { + setOnClickListener(null) + isClickable = false + textInputLayout.setViewClickListener(listener) + } + else -> { + setOnClickListener(listener) + textInputLayout.setViewClickListener(listener) + } + } + + fun addViewFocusChangeListener(listener: ((AcqEditText) -> Unit)) = + textInputLayout.addFocusChangeListener(listener) + + fun removeViewFocusChangeListener(listener: ((AcqEditText) -> Unit)) = + textInputLayout.removeFocusChangeListener(listener) + + fun addLeftBauble(bauble: View, index: Int = 0) = textInputLayout.addLeftBauble(bauble, index) + + fun addRightBauble(bauble: View, index: Int = 0) = textInputLayout.addRightBauble(bauble, index) + + fun setLines(lines: Int) = editText.setLines(lines) + + fun setSelection(start: Int, end: Int = start) = textInputLayout.setSelection(start, end) + + fun showKeyboard() = editText.showKeyboard() + + fun hideKeyboard() = editText.hideKeyboard() + + override fun onSaveInstanceState(): Parcelable { + val superState = super.onSaveInstanceState()!! + val ss = SavedState(superState) + ss.childrenStates = SparseArray() + for (i in 0 until childCount) { + getChildAt(i).saveHierarchyState(ss.childrenStates) + } + return ss + } + + override fun onRestoreInstanceState(state: Parcelable) { + val savedState = state as SavedState + super.onRestoreInstanceState(savedState.superState) + for (i in 0 until childCount) { + getChildAt(i).restoreHierarchyState(savedState.childrenStates) + } + } + + private inner class SymbolCounter(val view: TextView) { + + init { + view.tag = (tag as? String)?.plus(SYMBOL_COUNTER_TAG_POSTFIX) + } + + private var visible = false + set(value) { + if (field == value) return + field = value + anim?.cancel() + when { + this@AcqTextFieldView.isLaidOut -> animateVisible(field) + else -> with(view) { + layoutParams.height = if (visible) ViewGroup.LayoutParams.WRAP_CONTENT else 0 + requestLayout() + } + } + } + + private var anim: Animator? = null + + fun update() { + if (maxSymbols <= 0 || !maxSymbolsCounterVisible || !editText.isFocused) { + visible = false + } else { + view.text = when (val length = editText.length()) { + 0 -> context.resources.getQuantityString( + R.plurals.acq_sf_max_symbol_counter_empty, + maxSymbols, maxSymbols) + else -> context.resources.getQuantityString( + R.plurals.acq_sf_max_symbol_counter_remaining, + maxSymbols - length, maxSymbols - length) + } + visible = true + } + } + + private fun animateVisible(visible: Boolean) { + val params = view.layoutParams + val startHeight = view.height + val targetHeight = if (visible) { + view.measure(makeMeasureSpec(width - paddingLeft - paddingRight, EXACTLY), UNSPECIFIED) + view.measuredHeight + } else 0 + val startAlpha = view.alpha + val targetAlpha = if (visible) 1f else 0f + anim = ValueAnimator.ofFloat(0f, 1f).apply { + addUpdateListener { + val fraction = it.animatedValue as Float + params.height = lerp(startHeight, targetHeight, fraction) + view.alpha = lerp(startAlpha, targetAlpha, fraction) + view.postOnAnimation { view.requestLayout() } + } + duration = SYMBOL_COUNTER_ANIM_DURATION + start() + } + } + } + + class SavedState : BaseSavedState { + + var childrenStates: SparseArray? = null + + constructor(superState: Parcelable) : super(superState) + + @Suppress("UNCHECKED_CAST") + private constructor(source: Parcel, classLoader: ClassLoader?) : super(source) { + childrenStates = source.readSparseArray(classLoader) + } + + @Suppress("UNCHECKED_CAST") + override fun writeToParcel(out: Parcel, flags: Int) { + super.writeToParcel(out, flags) + out.writeSparseArray(childrenStates as SparseArray) + } + + companion object { + @JvmField + val CREATOR: Parcelable.ClassLoaderCreator = + object : Parcelable.ClassLoaderCreator { + override fun createFromParcel( + source: Parcel, + loader: ClassLoader? + ): SavedState { + return SavedState(source, loader) + } + + override fun createFromParcel(source: Parcel): SavedState { + return createFromParcel(source, null) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + } + } + + protected companion object { + + const val SYMBOL_COUNTER_TAG_POSTFIX = "_symbol_counter" + private const val SYMBOL_COUNTER_ANIM_DURATION = 200L + + fun ViewGroup.recursiveSetEnabled(enabled: Boolean): Unit = forEachChild { + it.isEnabled = enabled + (it as? ViewGroup)?.recursiveSetEnabled(enabled) + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/smartfield/AcqTextInputLayout.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/smartfield/AcqTextInputLayout.kt new file mode 100644 index 00000000..b147fd33 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/smartfield/AcqTextInputLayout.kt @@ -0,0 +1,758 @@ +package ru.tinkoff.acquiring.sdk.smartfield + +import android.animation.Animator +import android.animation.ValueAnimator +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Bitmap +import android.graphics.BitmapShader +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.ComposeShader +import android.graphics.LinearGradient +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.PointF +import android.graphics.PorterDuff +import android.graphics.Rect +import android.graphics.Shader +import android.graphics.Typeface +import android.graphics.drawable.InsetDrawable +import android.text.Editable +import android.text.TextPaint +import android.text.method.TransformationMethod +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.annotation.DimenRes +import androidx.annotation.VisibleForTesting +import androidx.core.content.res.ResourcesCompat +import androidx.core.view.ViewCompat +import androidx.core.view.isGone +import androidx.core.view.updatePadding +import androidx.interpolator.view.animation.FastOutSlowInInterpolator +import ru.tinkoff.acquiring.sdk.R +import ru.tinkoff.acquiring.sdk.utils.SimpleTextWatcher.Companion.afterTextChanged +import ru.tinkoff.acquiring.sdk.utils.ViewUtil +import ru.tinkoff.acquiring.sdk.utils.dpToPx +import ru.tinkoff.acquiring.sdk.utils.forEachChild +import ru.tinkoff.acquiring.sdk.utils.horizontalMargin +import ru.tinkoff.acquiring.sdk.utils.horizontalPadding +import ru.tinkoff.acquiring.sdk.utils.lerp +import ru.tinkoff.acquiring.sdk.utils.measuredFullHeight +import ru.tinkoff.acquiring.sdk.utils.measuredFullWidth +import ru.tinkoff.acquiring.sdk.utils.spToPx +import ru.tinkoff.acquiring.sdk.utils.verticalMargin +import ru.tinkoff.acquiring.sdk.utils.verticalPadding +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.roundToLong + +internal open class AcqTextInputLayout +@JvmOverloads +constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ViewGroup(context, attrs, defStyleAttr) { + + lateinit var editText: AcqEditText + private set + + // state + private var expandedTitleTextSize: Float = context.spToPx(16f) + set(value) { + if (field == value) return + field = value + clearTitlePaintShaders() + invalidate() + } + private var collapsedTitleTextSize: Int = context.spToPx(DEFAULT_COLLAPSED_TITLE_TEXT_SIZE) + set(value) { + if (field == value) return + field = value + requestLayout() + } + private var collapsedTitleBottomMargin: Int = context.dpToPx(DEFAULT_TITLE_BOTTOM_MARGIN) + set(value) { + if (field == value) return + field = value + requestLayout() + } + private var defaultTitleTextColor: ColorStateList? = null + + var textEditable = true + set(value) { + field = value + editText.isFocusableInTouchMode = field + } + + var title: CharSequence? = null + set(value) { + if (field == value) return + field = value + clearTitlePaintShaders() + invalidate() + } + + var floatingTitle = true + set(value) { + if (field == value) return + field = value + updateTitleFraction(false) + requestLayout() + } + + var placeholder: CharSequence? + get() = editText.hint + set(value) { + editText.hint = value + } + + var appendix: String? + get() = editText.appendix + set(value) { + editText.appendix = value + } + + var appendixColorRes: Int + get() = editText.appendixColorRes + set(value) { + editText.appendixColorRes = value + } + + var appendixSide: Int + get() = editText.appendixSide + set(value) { + editText.appendixSide = value + } + + var textSize: Float + get() = editText.textSize / editText.paint.density + set(value) { + editText.textSize = value + } + + var fontFamily: Int = -1 + set(value) { + field = value + editText.setTypeface(ResourcesCompat.getFont(context, value), textStyle) + } + + var textStyle: Int = Typeface.NORMAL + set(value) { + field = value + editText.setTypeface(ResourcesCompat.getFont(context, fontFamily), value) + } + + var inputType: Int + get() = editText.inputType + set(value) { + editText.inputType = value + } + + var transformationMethod: TransformationMethod? + get() = editText.transformationMethod + set(value) { + editText.transformationMethod = value + } + + var maxLines: Int + get() = editText.maxLines + set(value) { + if (value < 0) { + if (editText.maxLines >= 0) { + editText.maxHeight = Int.MAX_VALUE + } + } else { + editText.maxLines = value + } + } + + var maxSymbols: Int + get() = editText.maxSymbols + set(value) { + if (editText.maxSymbols <= 0 && value <= 0) return + editText.maxSymbols = value + } + + var text: String? + get() = editText.text?.toString() + set(value) { + setText(value) + } + + private var _errorHighlighted = false + set(value) { + field = value + refreshDrawableState() + } + var errorHighlighted: Boolean + get() = _errorHighlighted + set(value) { + if (floatingTitle) { + _errorHighlighted = value + editText.errorHighlighted = false + } else { + // ниже не совсем корректно, потому что + // 1) при наличии фокуса у поля поле _errorHighlighted должно быть false + // 2) при отсутствии фокуса и текста у поля поле editText.errorHighlighted должно быть false + // но, в момент изменении поля errorHighlighted мы не можем точно узнать наличие фокуса, + // именно поэтому оставляем такой код, который дает нам точную информацию, что ошибка отображена, + // но не дает нам точной информации кто именно ее отображает + _errorHighlighted = value + editText.errorHighlighted = value + } + } + + var pseudoFocused: Boolean + get() = editText.pseudoFocused + set(value) { + editText.pseudoFocused = value + } + + var titleTextColor: ColorStateList? = null + set(value) { + field = value + defaultTitleTextColor = value + refreshDrawableState() + } + + @VisibleForTesting + var currentTitleTextColor: Int = DEFAULT_TITLE_TEXT_COLOR + private set + + /** + * Current animation stage of title. + * + * If [floatingTitle] is enabled this determines scale and position of title, if + * [floatingTitle] is disabled - it's alpha value. + * + * - 1 - title fully expanded (drawn in place of editText's text) or fully opaque + * - 0 - title fully collapsed (drawn above editText's text) or fully transparent + * + * It also affects alpha of editText both when [floatingTitle] is enabled or disabled. + */ + private var titleFraction: Float = 1f + private val titlePos = PointF() + private var titleScale = 1f + private var titleAnim: Animator? = null + + private val collapsedBounds = Rect() + private val expandedBounds = Rect() + + private var titlePaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG) + private var titleTexture: Bitmap? = null + private var titleShader: Shader? = null + private val tmpRect = Rect() + private val tmpMatrix = Matrix() + private var currentTitleTextColorShaderCache: Int? = null + private var expandedBoundsWidthShaderCache: Int? = null + + var nextPressedListener: (() -> Unit)? = null + var textChangedCallback: ((Editable?) -> Unit)? = null + var focusChangeCallback: ((AcqEditText) -> Unit)? = null + var keyboardBackPressedListener: (() -> Unit)? + get() = editText.keyboardBackPressedListener + set(value) { + editText.keyboardBackPressedListener = value + } + + private val focusChangeListeners = arrayListOf<((AcqEditText) -> Unit)>() + + init { + @Suppress("LeakingThis") + setWillNotDraw(false) + + if (attrs != null) { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.AcqTextInputLayout, defStyleAttr, 0) + + title = typedArray.getString(R.styleable.AcqTextInputLayout_acq_til_title) + floatingTitle = typedArray.getBoolean(R.styleable.AcqTextInputLayout_acq_til_title_enabled, true) + collapsedTitleTextSize = typedArray.getDimensionPixelSize( + R.styleable.AcqTextInputLayout_acq_til_title_text_size, collapsedTitleTextSize + ) + defaultTitleTextColor = typedArray.getColorStateList(R.styleable.AcqTextInputLayout_acq_til_title_text_color) + ?: ColorStateList.valueOf(DEFAULT_TITLE_TEXT_COLOR) + titleTextColor = defaultTitleTextColor + collapsedTitleBottomMargin = typedArray.getDimensionPixelSize( + R.styleable.AcqTextInputLayout_acq_til_title_bottom_margin, collapsedTitleBottomMargin + ) + + typedArray.recycle() + } + + setInsets(0, 0) + } + + override fun onCreateDrawableState(extraSpace: Int): IntArray { + val states = arrayListOf() + if (_errorHighlighted) { + states.add(R.attr.acq_sf_state_error) + } + val state = super.onCreateDrawableState(extraSpace + states.size) + mergeDrawableStates(state, states.toIntArray()) + val focusedIndex = state.indexOf(android.R.attr.state_focused) + if (focusedIndex != -1) { + state[focusedIndex] = 0 + } + return state + } + + override fun childDrawableStateChanged(child: View?) { + if (child === editText) { + refreshDrawableState() + } else { + super.childDrawableStateChanged(child) + } + } + + override fun drawableStateChanged() { + super.drawableStateChanged() + currentTitleTextColor = titleTextColor?.getColorForState( + drawableState, DEFAULT_TITLE_TEXT_COLOR + ) ?: DEFAULT_TITLE_TEXT_COLOR + invalidate() + } + + fun setInsets(left: Int = 0, right: Int = 0) { + val padding = context.dpToPx(HORIZONTAL_PADDING) + val drawable = (background as? InsetDrawable)?.drawable ?: background + background = InsetDrawable(drawable, left, right, 0, 0) + updatePadding(left = left + padding, right = right + padding) + } + + @Suppress("ComplexCondition") + private fun updateTitleFraction(animate: Boolean = true) { + titleAnim?.cancel() + + val targetFraction = if (editText.text.isNullOrEmpty() && !editText.hasFocus()) 1f else 0f + + // animating if necessary + if (animate && ViewCompat.isLaidOut(this) && titleFraction != targetFraction) { + animateTitleFraction(targetFraction) + } else { + setTitleFraction(targetFraction) + editText.invalidate() + invalidate() + } + } + + private fun setTitleFraction(fraction: Float) { + titleFraction = fraction + if (floatingTitle) { + titlePaint.alpha = ALPHA_MAX + editText.alpha = 1 - fraction + titleScale = (titleFraction * expandedTitleTextSize + + (1 - titleFraction) * collapsedTitleTextSize) / expandedTitleTextSize + titlePos.set( + lerp(collapsedBounds.left.toFloat(), expandedBounds.left.toFloat(), titleFraction), + lerp(collapsedBounds.top.toFloat(), expandedBounds.top.toFloat(), titleFraction) + ) + } else { + titlePaint.alpha = (fraction * ALPHA_MAX).toInt() + editText.alpha = if (title.isNullOrEmpty()) 1f else 1 - fraction + titleScale = 1f + titlePos.set(expandedBounds.left.toFloat(), expandedBounds.top.toFloat()) + } + } + + private fun animateTitleFraction(fraction: Float) { + titleAnim = ValueAnimator.ofFloat(titleFraction, fraction).apply { + addUpdateListener { + setTitleFraction(it.animatedValue as Float) + ViewCompat.postInvalidateOnAnimation(editText) + ViewCompat.postInvalidateOnAnimation(this@AcqTextInputLayout) + } + interpolator = TITLE_INTERPOLATOR + duration = (abs(titleFraction - fraction) * ANIMATION_DURATION).roundToLong() + titleAnim?.cancel() + start() + } + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + drawTitle(canvas) + } + + @VisibleForTesting + fun drawTitle(canvas: Canvas) { + if (!isDrawingTitle()) return + ensureTitlePaintShader() ?: return + + val canvasSave = canvas.save() + canvas.translate(titlePos.x, titlePos.y) + titleShader!!.setLocalMatrix(tmpMatrix.apply { + reset() + preScale(titleScale, titleScale) + }) + canvas.drawPaint(titlePaint) + canvas.restoreToCount(canvasSave) + } + + @VisibleForTesting + fun isDrawingTitle(): Boolean = (floatingTitle || titleFraction != 0f) && !title.isNullOrBlank() + + override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams) { + if (child is AcqEditText) { + super.addView(child, index, params) + editText = child + onEditTextSet() + } else { + super.addView(child, index, params) + } + } + + private fun onEditTextSet() { + editText.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_NEXT) { + nextPressedListener?.invoke() + return@setOnEditorActionListener true + } + false + } + + editText.afterTextChanged { + updateTitleFraction() + textChangedCallback?.invoke(it) + } + + editText.setOnFocusChangeListener { editText, _ -> + editText as AcqEditText + updateTitleFraction() + if (!editText.isFocused && !textEditable) { + // probably textEditable just changed, we don't want to trigger focus change + // event for consumer but rather change focus variant to pseudoFocus + pseudoFocused = true + } else { + focusChangeCallback?.invoke(this.editText) + } + if (editText.isFocused) { + editText.setSelection(editText.text?.length ?: 0) + } + focusChangeListeners.forEach { it.invoke(editText) } + } + updateTitleFraction(false) + } + + @Suppress("ComplexMethod") + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val widthSize = MeasureSpec.getSize(widthMeasureSpec) + val widthMode = MeasureSpec.getMode(widthMeasureSpec) + val heightSize = MeasureSpec.getSize(heightMeasureSpec) + val heightMode = MeasureSpec.getMode(heightMeasureSpec) + + val availableHeight = heightSize - verticalPadding() + var occupiedHeight = verticalPadding() + var occupiedWidth = horizontalPadding() + + fun measureChild(child: View) { + if (child.isGone) return + val params = child.params() + + check(child == editText || params.width != ViewGroup.LayoutParams.MATCH_PARENT) { + "Bauble views can't have MATCH_PARENT width" + } + + child.measure( + when (child) { + editText -> when (widthMode) { + MeasureSpec.UNSPECIFIED -> MeasureSpec.UNSPECIFIED + MeasureSpec.EXACTLY -> MeasureSpec.makeMeasureSpec( + widthSize - occupiedWidth - params.horizontalMargin(), MeasureSpec.EXACTLY + ) + else -> MeasureSpec.makeMeasureSpec( + widthSize - occupiedWidth - params.horizontalMargin(), MeasureSpec.AT_MOST + ) + } + else -> MeasureSpec.UNSPECIFIED + }, + when (heightMode) { + MeasureSpec.UNSPECIFIED -> MeasureSpec.UNSPECIFIED + else -> MeasureSpec.makeMeasureSpec( + availableHeight - + params.verticalMargin(), MeasureSpec.AT_MOST + ) + } + ) + + occupiedWidth += child.measuredFullWidth() + occupiedHeight = maxOf(occupiedHeight, child.measuredFullHeight() + verticalPadding()) + } + + forEachChild { child -> if (child !== editText) measureChild(child) } + measureChild(editText) + + occupiedHeight = occupiedHeight.coerceAtLeast(minimumHeight) + + val measuredWidth = when (widthMode) { + MeasureSpec.UNSPECIFIED -> occupiedWidth + MeasureSpec.AT_MOST -> minOf(occupiedWidth, widthSize) + else -> widthSize + } + val measuredHeight = when (heightMode) { + MeasureSpec.UNSPECIFIED -> occupiedHeight + MeasureSpec.AT_MOST -> minOf(occupiedHeight, heightSize) + else -> heightSize + } + + setMeasuredDimension(measuredWidth, measuredHeight) + } + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + val availableHeight = b - t + val childBottom = availableHeight - paddingBottom + var childLeft = paddingLeft + + forEachChild { child -> + if (child.isGone) return@forEachChild + val params = child.params() + val childFullWidth = child.measuredFullWidth() + val childFullHeight = child.measuredFullHeight() + + val verticalGravityOffset = if (childFullHeight < availableHeight) + (availableHeight - childFullHeight) / 2 else 0 + + child.layout( + childLeft + params.leftMargin, paddingTop + verticalGravityOffset + params.topMargin, + childLeft + childFullWidth, childBottom - verticalGravityOffset - params.bottomMargin + ) + + childLeft += childFullWidth + } + + recalculateTitleBounds() + setTitleFraction(titleFraction) + } + + private fun recalculateTitleBounds() { + val rect = tmpRect.also { ViewUtil.getDescendantRect(this, editText, it) } + + val left = rect.left + val right = rect.right + val top = rect.top + editText.compoundPaddingTop + val bottom = rect.bottom - editText.compoundPaddingBottom + val expandedTitleHeight = expandedTitleTextSize + val offset = ((bottom - top - expandedTitleHeight) / 2).toInt() + expandedBounds.set(left, top + offset, right, bottom - offset) + + if (floatingTitle) { + val emptySpace = measuredHeight - editText.measuredHeight + val collapsedTitleHeight = collapsedTitleTextSize + collapsedTitleBottomMargin + val remainingSpace = emptySpace - collapsedTitleHeight + val titleTopOffset = context.dpToPx(TITLE_TOP_OFFSET) + val collapsedTitleTopMargin = (remainingSpace / 2 + titleTopOffset).coerceAtLeast(0) + collapsedBounds.set( + left, collapsedTitleTopMargin, + right, collapsedTitleTopMargin + collapsedTitleTextSize + ) + val textOffset = (collapsedTitleHeight + collapsedTitleTopMargin - emptySpace / 2) + .coerceAtLeast(0) + editText.layout(rect.left, rect.top + textOffset, rect.right, rect.bottom + textOffset) + } + } + + private fun ensureTitlePaintShader(): Shader? { + val currentTitleTextColor = currentTitleTextColor + val expandedBoundsWidth = expandedBounds.width().takeIf { it != 0 } ?: return null + titlePaint.shader?.let { + if (currentTitleTextColorShaderCache != currentTitleTextColor) return@let + if (expandedBoundsWidthShaderCache != expandedBoundsWidth) return@let + return it + } + currentTitleTextColorShaderCache = currentTitleTextColor + expandedBoundsWidthShaderCache = expandedBoundsWidth + + clearTitlePaintShaders() + + titleShader = createTitleShader() ?: return null + val fadeShader = createFadedEllipsizeShader(expandedBoundsWidth) + return ComposeShader(titleShader!!, fadeShader, PorterDuff.Mode.DST_IN).also { + titlePaint.shader = it + } + } + + private fun createTitleShader(): BitmapShader? { + val title = title.takeIf { !it.isNullOrEmpty() }?.toString() ?: return null + + val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG) + textPaint.textSize = expandedTitleTextSize + val fontMetrics = Paint.FontMetrics() + textPaint.getFontMetrics(fontMetrics) + + textPaint.color = currentTitleTextColor + val textWidth = ceil(textPaint.measureText(title)).toInt() + val textHeight = ceil(fontMetrics.descent - fontMetrics.ascent).toInt() + if (textWidth <= 0 || textHeight <= 0) return null + + titleTexture = Bitmap.createBitmap(textWidth + 1, textHeight + 1, Bitmap.Config.ARGB_8888) + Canvas(titleTexture!!).drawText(title, 0.0f, textHeight - fontMetrics.descent, textPaint) + return BitmapShader(titleTexture!!, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) + } + + private fun clearTitlePaintShaders() { + titlePaint.shader = null + titleShader = null + titleTexture?.recycle() + titleTexture = null + } + + fun addFocusChangeListener(listener: ((AcqEditText) -> Unit)) { + this.focusChangeListeners.add(listener) + } + + fun removeFocusChangeListener(listener: ((AcqEditText) -> Unit)) { + this.focusChangeListeners.remove(listener) + } + + //region bauble + private val leftBaubles = mutableListOf() + private val rightBaubles = mutableListOf() + + fun addLeftBauble(bauble: View, index: Int) { + leftBaubles.add(index, bauble) + val viewIndex = when { + index > 0 -> indexOfChild(leftBaubles[index - 1]) + 1 + else -> 0 + } + addView(bauble, viewIndex) + } + + fun addRightBauble(bauble: View, index: Int) { + rightBaubles.add(index, bauble) + val viewIndex = when { + index > 0 -> indexOfChild(rightBaubles[index - 1]) + 1 + else -> indexOfChild(editText) + 1 + } + addView(bauble, viewIndex) + } + + override fun onViewRemoved(child: View?) { + super.onViewRemoved(child) + check(child != editText) { "Edit text can't be removed" } + leftBaubles.remove(child) + rightBaubles.remove(child) + } + //endregion + + fun setViewClickListener(listener: ((View) -> Unit)?) = + when (listener) { + null -> { + editText.setOnClickListener(null) + editText.isClickable = false + } + else -> editText.setOnClickListener(listener) + } + + fun requestViewFocus(): Boolean = when { + textEditable -> requestInputFocus() + else -> { + pseudoFocused = true + true + } + } + + fun clearViewFocus() { + clearInputFocus() + pseudoFocused = false + } + + fun requestInputFocus(): Boolean { + if (!textEditable) return false + + editText.isFocusableInTouchMode = true + editText.forceLayout() // view can be zero-sized and canTakeFocus() will return false + return editText.requestFocus() + } + + fun clearInputFocus() = editText.clearFocus() + + fun isViewFocused(): Boolean = editText.isFocused || pseudoFocused + + fun setText(text: CharSequence?, resetCursor: Boolean = false) { + if (editText.text != text) { + editText.setText(text) + } + if (resetCursor) { + editText.setSelection(editText.text?.length ?: 0) + } + } + + fun setSelection(start: Int, end: Int = start) = editText.setSelection(start, end) + + fun setCollapsedTitleTextSizeRes(@DimenRes textSizeRes: Int) { + collapsedTitleTextSize = context.resources.getDimensionPixelSize(textSizeRes) + } + + fun setCollapsedTitleBottomMarginRes(@DimenRes bottomMarginRes: Int) { + collapsedTitleBottomMargin = context.resources.getDimensionPixelSize(bottomMarginRes) + } + + fun setTitleTextColor(@ColorInt titleTextColor: Int) { + this.titleTextColor = ColorStateList.valueOf(titleTextColor) + } + + fun setTitleTextColorRes(@ColorRes titleTextColorRes: Int) { + titleTextColor = ResourcesCompat.getColorStateList(context.resources, titleTextColorRes, context.theme) + } + + override fun checkLayoutParams(p: ViewGroup.LayoutParams?): Boolean = p is LayoutParams + + override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams = + LayoutParams(context, attrs) + + public override fun generateLayoutParams(p: ViewGroup.LayoutParams?): LayoutParams = when (p) { + null -> generateDefaultLayoutParams() + is LayoutParams -> LayoutParams(p) + is MarginLayoutParams -> LayoutParams(p) + else -> LayoutParams(p) + } + + override fun generateDefaultLayoutParams(): LayoutParams = + LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) + + @Suppress("UtilityClassWithPublicConstructor") + class LayoutParams : MarginLayoutParams { + + @JvmOverloads + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) + + constructor(width: Int, height: Int) : super(width, height) + + constructor(source: ViewGroup.LayoutParams) : super(source) + + constructor(source: MarginLayoutParams) : super(source) + } + + companion object { + + private val TITLE_INTERPOLATOR = FastOutSlowInInterpolator() + private const val ANIMATION_DURATION = 200L + private const val TITLE_TOP_OFFSET = 1 + private const val ALPHA_MAX = 255 + private const val HORIZONTAL_PADDING = 12 + + /** + * Relative width at which title ending starts to fade out + */ + const val FADED_ELLIPSIZE_RATIO = 0.8f + + private const val DEFAULT_COLLAPSED_TITLE_TEXT_SIZE = 13 // sp + private const val DEFAULT_TITLE_BOTTOM_MARGIN = 4 // dp + private const val DEFAULT_TITLE_TEXT_COLOR = Color.BLACK + + private fun createFadedEllipsizeShader(width: Int): Shader = LinearGradient( + 0f, 0f, width.toFloat(), 0f, intArrayOf(Color.BLACK, Color.TRANSPARENT), + floatArrayOf(FADED_ELLIPSIZE_RATIO, 1f), Shader.TileMode.CLAMP + ) + + private val PSEUDO_FOCUS_STATE = R.attr.acq_sf_state_pseudo_focus + private val FOCUS_STATE = android.R.attr.state_focused + + private fun View.params() = layoutParams as LayoutParams + } +} diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/smartfield/BaubleClearButton.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/smartfield/BaubleClearButton.kt new file mode 100644 index 00000000..2937f796 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/smartfield/BaubleClearButton.kt @@ -0,0 +1,32 @@ +package ru.tinkoff.acquiring.sdk.smartfield + +import android.view.ViewGroup +import android.widget.ImageView +import androidx.core.view.isVisible +import ru.tinkoff.acquiring.sdk.R +import ru.tinkoff.acquiring.sdk.utils.SimpleTextWatcher +import ru.tinkoff.acquiring.sdk.utils.dpToPx + +internal class BaubleClearButton { + + private lateinit var textFieldView: AcqTextFieldView + private lateinit var view: ImageView + + fun attach(textFieldView: AcqTextFieldView) { + this.textFieldView = textFieldView + + val context = textFieldView.context + view = ImageView(context).apply { + setImageResource(R.drawable.acq_ic_clear) + layoutParams = ViewGroup.LayoutParams(context.dpToPx(16), context.dpToPx(16)) + } + textFieldView.addRightBauble(view) + + textFieldView.addViewFocusChangeListener { update() } + textFieldView.editText.addTextChangedListener(SimpleTextWatcher.after { update() }) + } + + private fun update() { + view.isVisible = textFieldView.isEnabled && textFieldView.isViewFocused() + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/HapticUtil.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/HapticUtil.kt new file mode 100644 index 00000000..2aae7fa1 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/HapticUtil.kt @@ -0,0 +1,38 @@ +package ru.tinkoff.acquiring.sdk.utils + +import android.content.Context +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.provider.Settings + +@Suppress("MagicNumber") +internal object HapticUtil { + + fun performErrorHaptic(context: Context) { + if (!isSystemHapticEnabled(context)) return + + val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createWaveform(longArrayOf(0, 7, 120, 4), -1)) + } else { + vibrator.vibrate(longArrayOf(0, 7, 120, 4), -1) + } + } + + fun performWarningHaptic(context: Context) { + if (!isSystemHapticEnabled(context)) return + + val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createOneShot(7, VibrationEffect.DEFAULT_AMPLITUDE)) + } else { + vibrator.vibrate(7) + } + } + + fun isSystemHapticEnabled(context: Context): Boolean { + return Settings.System.getInt(context.contentResolver, + Settings.System.HAPTIC_FEEDBACK_ENABLED, 0) != 0 + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/SimpleTextWatcher.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/SimpleTextWatcher.kt new file mode 100644 index 00000000..27073f12 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/SimpleTextWatcher.kt @@ -0,0 +1,46 @@ +package ru.tinkoff.acquiring.sdk.utils + +import android.text.Editable +import android.text.TextWatcher +import android.widget.EditText + +internal class SimpleTextWatcher +private constructor( + private val beforeTextChanged: ((CharSequence?) -> Unit)? = null, + private val onTextChanged: ((CharSequence?) -> Unit)? = null, + private val afterTextChanged: ((Editable?) -> Unit)? = null +) : TextWatcher { + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { + beforeTextChanged?.invoke(s) + } + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + onTextChanged?.invoke(s) + } + + override fun afterTextChanged(s: Editable?) { + afterTextChanged?.invoke(s) + } + + companion object { + + fun before(beforeTextChanged: (CharSequence?) -> Unit) = + SimpleTextWatcher(beforeTextChanged = beforeTextChanged) + + fun onChanged(onTextChanged: (CharSequence?) -> Unit) = + SimpleTextWatcher(onTextChanged = onTextChanged) + + fun after(afterTextChanged: (Editable?) -> Unit) = + SimpleTextWatcher(afterTextChanged = afterTextChanged) + + internal fun EditText.beforeTextChanged(beforeTextChanged: (CharSequence?) -> Unit) = + addTextChangedListener(before(beforeTextChanged)) + + internal fun EditText.onTextChanged(onTextChanged: (CharSequence?) -> Unit) = + addTextChangedListener(onChanged(onTextChanged)) + + internal fun EditText.afterTextChanged(afterTextChanged: (Editable?) -> Unit) = + addTextChangedListener(after(afterTextChanged)) + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/ViewExtUtil.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/ViewExtUtil.kt new file mode 100644 index 00000000..551a70e2 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/ViewExtUtil.kt @@ -0,0 +1,123 @@ +package ru.tinkoff.acquiring.sdk.utils + +import android.content.Context +import android.graphics.Matrix +import android.graphics.Rect +import android.graphics.RectF +import android.util.TypedValue +import android.view.View +import android.view.ViewGroup +import android.view.ViewParent +import kotlin.math.roundToInt +import kotlin.math.roundToLong + +internal object ViewUtil { + + private const val ERROR_SHAKE_AMPLITUDE = 3f // dp + private const val ERROR_SHAKE_DURATION = 500L + private const val ERROR_SHAKE_CYCLES = 3 + + // Methods using these buffer objects are meant to be used in single (UI) thread + private val tMatrix = Matrix() + private val tRectF = RectF() + + fun getDescendantRect(parent: ViewGroup, descendant: View, rect: Rect) { + rect.set(0, 0, descendant.width, descendant.height) + offsetDescendantRect(parent, descendant, rect) + } + + fun offsetDescendantRect(parent: ViewGroup, descendant: View, rect: Rect) { + tMatrix.reset() + offsetDescendantMatrix(parent, descendant, tMatrix) + tRectF.set(rect) + tMatrix.mapRect(tRectF) + rect.set((tRectF.left + 0.5f).toInt(), (tRectF.top + 0.5f).toInt(), (tRectF.right + 0.5f).toInt(), (tRectF.bottom + 0.5f).toInt()) + } + + private fun offsetDescendantMatrix(target: ViewParent, view: View, matrix: Matrix) { + val parent = view.parent + if (parent is View && parent !== target) { + offsetDescendantMatrix(target, parent, matrix) + matrix.preTranslate((-parent.scrollX).toFloat(), (-parent.scrollY).toFloat()) + } + + matrix.preTranslate(view.left.toFloat(), view.top.toFloat()) + if (!view.matrix.isIdentity) { + matrix.preConcat(view.matrix) + } + } +} + +internal fun Context.dpToPx(dp: Float): Float = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics) + +internal fun Context.dpToPx(dp: Int): Int = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp.toFloat(), resources.displayMetrics).toInt() + +internal fun Context.spToPx(sp: Float): Float = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, resources.displayMetrics) + +internal fun Context.spToPx(sp: Int): Int = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp.toFloat(), resources.displayMetrics).toInt() + +internal fun View.horizontalPadding(): Int { + return paddingLeft + paddingRight +} + +internal fun View.setHorizontalPadding(padding: Int) { + setPadding(padding, paddingTop, padding, paddingBottom) +} + +internal fun View.verticalPadding(): Int { + return paddingTop + paddingBottom +} + +internal fun View.setVerticalPadding(padding: Int) { + setPadding(paddingLeft, padding, paddingRight, padding) +} + +internal fun ViewGroup.LayoutParams.horizontalMargin(): Int { + return (this as ViewGroup.MarginLayoutParams).leftMargin + rightMargin +} + +internal fun ViewGroup.LayoutParams.verticalMargin(): Int { + return (this as ViewGroup.MarginLayoutParams).topMargin + bottomMargin +} + +internal fun ViewGroup.LayoutParams.setHorizontalMargin(margin: Int) = + with(this as ViewGroup.MarginLayoutParams) { + leftMargin = margin + rightMargin = margin + } + +internal fun ViewGroup.LayoutParams.setVerticalMargin(margin: Int) = + with(this as ViewGroup.MarginLayoutParams) { + topMargin = margin + bottomMargin = margin + } + +internal fun View.measuredFullWidth(): Int { + return measuredWidth + layoutParams.horizontalMargin() +} + +internal fun View.measuredFullHeight(): Int { + return measuredHeight + layoutParams.verticalMargin() +} + +internal fun ViewGroup.forEachChild(action: (child: View) -> Unit) { + for (i in 0 until childCount) { + action(getChildAt(i)) + } +} + +internal fun lerp(start: Int, end: Int, fraction: Float): Int { + return (start + (end - start) * fraction).roundToInt() +} + +internal fun lerp(start: Long, end: Long, fraction: Float): Long { + return (start + (end - start) * fraction).roundToLong() +} + +internal fun lerp(start: Float, end: Float, fraction: Float): Float { + return (start + (end - start) * fraction) +} \ No newline at end of file diff --git a/ui/src/main/res/drawable/acq_ic_clear.xml b/ui/src/main/res/drawable/acq_ic_clear.xml new file mode 100644 index 00000000..ead5b52f --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_clear.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/ui/src/main/res/drawable/acq_sf_cursor.xml b/ui/src/main/res/drawable/acq_sf_cursor.xml new file mode 100644 index 00000000..6879bea7 --- /dev/null +++ b/ui/src/main/res/drawable/acq_sf_cursor.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/drawable/acq_sf_hint_selector.xml b/ui/src/main/res/drawable/acq_sf_hint_selector.xml new file mode 100644 index 00000000..5008ea31 --- /dev/null +++ b/ui/src/main/res/drawable/acq_sf_hint_selector.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/drawable/acq_sf_primary_text_selector.xml b/ui/src/main/res/drawable/acq_sf_primary_text_selector.xml new file mode 100644 index 00000000..37d44b3f --- /dev/null +++ b/ui/src/main/res/drawable/acq_sf_primary_text_selector.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/drawable/acq_sf_text_field_bg.xml b/ui/src/main/res/drawable/acq_sf_text_field_bg.xml new file mode 100644 index 00000000..4cc6c913 --- /dev/null +++ b/ui/src/main/res/drawable/acq_sf_text_field_bg.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/drawable/acq_sf_title_text_selector.xml b/ui/src/main/res/drawable/acq_sf_title_text_selector.xml new file mode 100644 index 00000000..5864e6f1 --- /dev/null +++ b/ui/src/main/res/drawable/acq_sf_title_text_selector.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_layout_text_field.xml b/ui/src/main/res/layout/acq_layout_text_field.xml new file mode 100644 index 00000000..fcea54f3 --- /dev/null +++ b/ui/src/main/res/layout/acq_layout_text_field.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/values-night/colors.xml b/ui/src/main/res/values-night/colors.xml index 1fbfbb43..85141bfb 100644 --- a/ui/src/main/res/values-night/colors.xml +++ b/ui/src/main/res/values-night/colors.xml @@ -25,8 +25,11 @@ #F6F7F8 @color/acq_colorTitle - #CF6679 + #4dffffff #727272 @color/acq_colorMain + + #1affffff + #26ffffff \ No newline at end of file diff --git a/ui/src/main/res/values-ru/strings.xml b/ui/src/main/res/values-ru/strings.xml index ea027a84..a7694cd6 100644 --- a/ui/src/main/res/values-ru/strings.xml +++ b/ui/src/main/res/values-ru/strings.xml @@ -24,4 +24,17 @@ Выберите приложение + + Доступен %d символ + Доступно %d символа + Доступно %d символов + Доступно %d символов + + + Остался %d символ + Осталось %d символа + Осталось %d символов + Осталось %d символов + + \ No newline at end of file diff --git a/ui/src/main/res/values/attrs.xml b/ui/src/main/res/values/attrs.xml index a38aa96a..6605fbe6 100644 --- a/ui/src/main/res/values/attrs.xml +++ b/ui/src/main/res/values/attrs.xml @@ -60,4 +60,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/values/colors.xml b/ui/src/main/res/values/colors.xml index 68240d0f..1a624fd6 100644 --- a/ui/src/main/res/values/colors.xml +++ b/ui/src/main/res/values/colors.xml @@ -25,6 +25,8 @@ #F6F7F8 #000000 #333333 + #ff9299a2 + #38001024 #C7C9CC #9E9E9E #4D000000 @@ -46,4 +48,7 @@ #FFFFFF #2CFFFFFF @color/acq_colorBrandGray + + #08001024 + #0f001024 diff --git a/ui/src/main/res/values/dimens.xml b/ui/src/main/res/values/dimens.xml index f37ccc40..17f73416 100644 --- a/ui/src/main/res/values/dimens.xml +++ b/ui/src/main/res/values/dimens.xml @@ -72,4 +72,12 @@ 28dp 35dp 8dp + + 16dp + 6dp + 56dp + 16dp + 16sp + 14sp + 13sp diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index e09d5127..b453f399 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -26,4 +26,13 @@ fps Tinkoff + + %d symbol available + %d symbols available + + + %d symbol remaining + %d symbols remaining + + diff --git a/ui/src/main/res/values/styles.xml b/ui/src/main/res/values/styles.xml index e6bfea36..f06a41ec 100644 --- a/ui/src/main/res/values/styles.xml +++ b/ui/src/main/res/values/styles.xml @@ -216,4 +216,25 @@ ?colorAccent + + + + From 6ba0af131ff82cd65d7352b5604e1c08cb8988c5 Mon Sep 17 00:00:00 2001 From: jqwout Date: Wed, 2 Nov 2022 15:46:12 +0300 Subject: [PATCH 002/126] =?UTF-8?q?=D0=A0=D0=B5=D0=B4=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D0=B9=D0=BD.=D0=A3=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BA=D0=B0=D1=80=D1=82=D0=B0=D0=BC=D0=B8.=20?= =?UTF-8?q?=D0=AD=D0=BA=D1=80=D0=B0=D0=BD=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BA=D0=B0=D1=80=D1=82.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/build.gradle | 1 + .../sdk/requests/AcquiringRequest.kt | 16 + .../sdk/requests/GetCardListRequest.kt | 9 + .../acquiring/sdk/utils/RequestResult.kt | 21 + gradle/versions.gradle | 8 +- .../acquiring/sample/ui/MainActivity.kt | 2 +- sample/src/main/res/values-ru/strings.xml | 2 + ui/build.gradle | 7 +- ui/src/main/AndroidManifest.xml | 5 + .../tinkoff/acquiring/sdk/TinkoffAcquiring.kt | 8 +- .../cards/list/adapters/CardsListAdapter.kt | 57 +++ .../cards/list/models/CardItemUiModel.kt | 17 + .../list/presentation/CardsListViewModel.kt | 78 ++++ .../cards/list/ui/CardsListActivity.kt | 88 ++++ .../redesign/cards/list/ui/CardsListState.kt | 12 + .../redesign/common/util/ShimmerAnimation.kt | 61 +++ .../sdk/ui/activities/PaymentActivity.kt | 2 +- .../sdk/ui/activities/SavedCardsActivity.kt | 383 ------------------ .../sdk/ui/activities/TransparentActivity.kt | 16 +- .../sdk/ui/fragments/PaymentFragment.kt | 1 - .../acquiring/sdk/utils/CoroutineManager.kt | 2 + .../tinkoff/acquiring/sdk/utils/FlipperExt.kt | 11 + .../sdk/viewmodel/SavedCardsViewModel.kt | 97 ----- .../sdk/viewmodel/ViewModelProviderFactory.kt | 21 +- .../res/drawable/acq_shimmer_rectangle_bg.xml | 6 + ui/src/main/res/drawable/tui_avatar.xml | 14 + .../res/layout/acq_activity_card_list.xml | 55 +++ .../res/layout/acq_base_card_activity.xml | 44 ++ .../acq_card_list_card_item_shimmer.xml | 34 ++ .../main/res/layout/acq_card_list_content.xml | 45 ++ .../res/layout/acq_card_list_empty_stub.xml | 28 ++ .../res/layout/acq_card_list_error_stub.xml | 27 ++ ui/src/main/res/layout/acq_card_list_item.xml | 38 ++ .../main/res/layout/acq_card_list_shimmer.xml | 32 ++ ui/src/main/res/values-night/colors.xml | 2 + ui/src/main/res/values-ru/strings.xml | 2 + ui/src/main/res/values/colors.xml | 2 + ui/src/main/res/values/strings.xml | 2 + .../cards/list/CardsBaseViewModelTest.kt | 117 ++++++ 39 files changed, 869 insertions(+), 504 deletions(-) create mode 100644 core/src/main/java/ru/tinkoff/acquiring/sdk/utils/RequestResult.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/models/CardItemUiModel.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/util/ShimmerAnimation.kt delete mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/SavedCardsActivity.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/FlipperExt.kt delete mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/SavedCardsViewModel.kt create mode 100644 ui/src/main/res/drawable/acq_shimmer_rectangle_bg.xml create mode 100644 ui/src/main/res/drawable/tui_avatar.xml create mode 100644 ui/src/main/res/layout/acq_activity_card_list.xml create mode 100644 ui/src/main/res/layout/acq_base_card_activity.xml create mode 100644 ui/src/main/res/layout/acq_card_list_card_item_shimmer.xml create mode 100644 ui/src/main/res/layout/acq_card_list_content.xml create mode 100644 ui/src/main/res/layout/acq_card_list_empty_stub.xml create mode 100644 ui/src/main/res/layout/acq_card_list_error_stub.xml create mode 100644 ui/src/main/res/layout/acq_card_list_item.xml create mode 100644 ui/src/main/res/layout/acq_card_list_shimmer.xml create mode 100644 ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsBaseViewModelTest.kt diff --git a/core/build.gradle b/core/build.gradle index 7ded78a0..d969115b 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -16,6 +16,7 @@ dependencies { implementation "com.squareup.okhttp3:okhttp:${okHttpVersion}" implementation "com.google.code.gson:gson:$gsonVersion" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" testImplementation 'junit:junit:4.13' } diff --git a/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/AcquiringRequest.kt b/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/AcquiringRequest.kt index 98d466a7..42625958 100644 --- a/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/AcquiringRequest.kt +++ b/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/AcquiringRequest.kt @@ -17,6 +17,9 @@ package ru.tinkoff.acquiring.sdk.requests import com.google.gson.Gson +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow import okhttp3.Response import ru.tinkoff.acquiring.sdk.AcquiringSdk import ru.tinkoff.acquiring.sdk.exceptions.NetworkException @@ -24,6 +27,7 @@ import ru.tinkoff.acquiring.sdk.network.AcquiringApi import ru.tinkoff.acquiring.sdk.network.NetworkClient import ru.tinkoff.acquiring.sdk.responses.AcquiringResponse import ru.tinkoff.acquiring.sdk.utils.Request +import ru.tinkoff.acquiring.sdk.utils.RequestResult import java.io.UnsupportedEncodingException import java.net.URLEncoder import java.security.PublicKey @@ -77,6 +81,18 @@ abstract class AcquiringRequest(internal val apiMethod: S client.call(request, responseClass, onSuccess, onFailure) } + protected fun performRequestFlow(request: AcquiringRequest, + responseClass: Class) : Flow> { + request.validate() + val client = NetworkClient() + val flow = MutableStateFlow>(RequestResult.NotYet) + client.call(request, responseClass, + onSuccess = { flow.tryEmit(RequestResult.Success(it)) }, + onFailure = { flow.tryEmit(RequestResult.Failure(it)) } + ) + return flow + } + @kotlin.jvm.Throws(NetworkException::class) protected fun performRequestRaw(request: AcquiringRequest): Response { request.validate() diff --git a/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/GetCardListRequest.kt b/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/GetCardListRequest.kt index ced90bbe..f2940183 100644 --- a/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/GetCardListRequest.kt +++ b/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/GetCardListRequest.kt @@ -16,8 +16,10 @@ package ru.tinkoff.acquiring.sdk.requests +import kotlinx.coroutines.flow.Flow import ru.tinkoff.acquiring.sdk.network.AcquiringApi.GET_CARD_LIST_METHOD import ru.tinkoff.acquiring.sdk.responses.GetCardListResponse +import ru.tinkoff.acquiring.sdk.utils.RequestResult /** * Возвращает список привязанных карт у покупателя @@ -49,4 +51,11 @@ class GetCardListRequest : AcquiringRequest(GET_CARD_LIST_M override fun execute(onSuccess: (GetCardListResponse) -> Unit, onFailure: (Exception) -> Unit) { super.performRequest(this, GetCardListResponse::class.java, onSuccess, onFailure) } + + /** + * Реактивный вызов метода API + */ + fun executeFlow(): Flow> { + return super.performRequestFlow(this, GetCardListResponse::class.java) + } } \ No newline at end of file diff --git a/core/src/main/java/ru/tinkoff/acquiring/sdk/utils/RequestResult.kt b/core/src/main/java/ru/tinkoff/acquiring/sdk/utils/RequestResult.kt new file mode 100644 index 00000000..091747a0 --- /dev/null +++ b/core/src/main/java/ru/tinkoff/acquiring/sdk/utils/RequestResult.kt @@ -0,0 +1,21 @@ +package ru.tinkoff.acquiring.sdk.utils + +import ru.tinkoff.acquiring.sdk.responses.AcquiringResponse + +sealed class RequestResult { + + object NotYet: RequestResult() + + class Success(val result: R) : RequestResult() + + class Failure(val exception: java.lang.Exception) : RequestResult() + + fun process(onSuccess: (R) -> Unit, onFailure: (java.lang.Exception) -> Unit) { + when (this) { + is Success -> onSuccess(result) + is Failure -> onFailure(exception) + } + } + + val isFinished get() = this !is NotYet +} \ No newline at end of file diff --git a/gradle/versions.gradle b/gradle/versions.gradle index 363d0c02..3681a643 100644 --- a/gradle/versions.gradle +++ b/gradle/versions.gradle @@ -17,7 +17,7 @@ ext { cardIoVersion = '5.5.1' gsonVersion = '2.8.6' coreNfcVersion = '1.0.2' - coroutinesVersion = '1.3.7' + coroutinesVersion = '1.6.4' googleWalletVersion = '18.0.0' constraintLayoutVersion = '1.1.3' @@ -29,6 +29,10 @@ ext { blurryVersion = '4.0.0' bouncyCastleVersion = '1.65' rootBeerVersion = '0.1.0' + lifecycleRuntimeVersion = '2.4.0' + recyclerviewVersion = '1.2.1' - mokitoKotlin = '4.0.0' + mokitoKotlinVersion = '4.0.0' + mokitoInlineVersion = '3.5.13' + turbineVersion = '0.12.0' } diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt index b817dbe4..efcf23d7 100644 --- a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt @@ -269,7 +269,7 @@ class MainActivity : AppCompatActivity(), BooksListAdapter.BookDetailsClickListe } } - SampleApplication.tinkoffAcquiring.openSavedCardsScreen(this, + SampleApplication.tinkoffAcquiring.openSavedCardsScreenV2(this, options, SAVED_CARDS_REQUEST_CODE) } diff --git a/sample/src/main/res/values-ru/strings.xml b/sample/src/main/res/values-ru/strings.xml index 655d21d8..a9aea359 100644 --- a/sample/src/main/res/values-ru/strings.xml +++ b/sample/src/main/res/values-ru/strings.xml @@ -82,4 +82,6 @@ Платежные уведомления Продление подписки Подписка успешно продлена! + + %1$s • %2$s \ No newline at end of file diff --git a/ui/build.gradle b/ui/build.gradle index 879a4615..d33398ca 100644 --- a/ui/build.gradle +++ b/ui/build.gradle @@ -52,16 +52,19 @@ dependencies { // threeds dependencies implementation "androidx.appcompat:appcompat:${appCompatVersion}" + implementation "androidx.lifecycle:lifecycle-runtime-ktx:${lifecycleRuntimeVersion}" implementation "androidx.constraintlayout:constraintlayout:${constraintLayoutVersion}" implementation "com.fasterxml.jackson.core:jackson-databind:${jacksonVersion}" implementation "com.nimbusds:nimbus-jose-jwt:${nimbusVersion}" implementation "org.bouncycastle:bcprov-jdk15on:${bouncyCastleVersion}" implementation "jp.wasabeef:blurry:${blurryVersion}" implementation "com.scottyab:rootbeer-lib:${rootBeerVersion}" + implementation "androidx.recyclerview:recyclerview:${recyclerviewVersion}" testImplementation 'junit:junit:4.13' - testImplementation "org.mockito.kotlin:mockito-kotlin:${mokitoKotlin}" - testImplementation 'org.mockito:mockito-inline:2.13.0' + testImplementation "org.mockito.kotlin:mockito-kotlin:${mokitoKotlinVersion}" + testImplementation "org.mockito:mockito-inline:${mokitoInlineVersion}" + testImplementation "app.cash.turbine:turbine:${turbineVersion}" androidTestImplementation 'com.android.support.test:runner:1.0.2' androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2' } diff --git a/ui/src/main/AndroidManifest.xml b/ui/src/main/AndroidManifest.xml index e2e38bd6..f41daa61 100644 --- a/ui/src/main/AndroidManifest.xml +++ b/ui/src/main/AndroidManifest.xml @@ -78,6 +78,11 @@ android:screenOrientation="unspecified" android:theme="@style/AcquiringNotificationTransparentTheme" /> + + diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt index 922fadcc..bc82a183 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt @@ -39,15 +39,13 @@ import ru.tinkoff.acquiring.sdk.models.paysources.AttachedCard import ru.tinkoff.acquiring.sdk.models.paysources.CardData import ru.tinkoff.acquiring.sdk.models.paysources.GooglePay import ru.tinkoff.acquiring.sdk.payment.PaymentProcess +import ru.tinkoff.acquiring.sdk.redesign.cards.list.ui.CardsListActivity import ru.tinkoff.acquiring.sdk.responses.TinkoffPayStatusResponse -import ru.tinkoff.acquiring.sdk.threeds.ThreeDsHelper import ru.tinkoff.acquiring.sdk.ui.activities.AttachCardActivity import ru.tinkoff.acquiring.sdk.ui.activities.BaseAcquiringActivity import ru.tinkoff.acquiring.sdk.ui.activities.NotificationPaymentActivity import ru.tinkoff.acquiring.sdk.ui.activities.PaymentActivity import ru.tinkoff.acquiring.sdk.ui.activities.QrCodeActivity -import ru.tinkoff.acquiring.sdk.ui.activities.SavedCardsActivity -import ru.tinkoff.acquiring.sdk.ui.activities.ThreeDsActivity /** * Точка входа для взаимодействия с Acquiring SDK @@ -253,7 +251,7 @@ class TinkoffAcquiring( * с параметром String по ключу [TinkoffAcquiring.EXTRA_CARD_ID] */ fun openSavedCardsScreen(activity: Activity, savedCardsOptions: SavedCardsOptions, requestCode: Int) { - val intent = prepareIntent(activity, savedCardsOptions, SavedCardsActivity::class.java) + val intent = prepareIntent(activity, savedCardsOptions, CardsListActivity::class.java) activity.startActivityForResult(intent, requestCode) } @@ -269,7 +267,7 @@ class TinkoffAcquiring( * с параметром String по ключу [TinkoffAcquiring.EXTRA_CARD_ID] */ fun openSavedCardsScreen(fragment: Fragment, savedCardsOptions: SavedCardsOptions, requestCode: Int) { - val intent = prepareIntent(fragment.requireContext(), savedCardsOptions, SavedCardsActivity::class.java) + val intent = prepareIntent(fragment.requireContext(), savedCardsOptions, CardsListActivity::class.java) fragment.startActivityForResult(intent, requestCode) } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt new file mode 100644 index 00000000..5059527e --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt @@ -0,0 +1,57 @@ +package ru.tinkoff.acquiring.sdk.redesign.cards.list.adapters + +import android.annotation.SuppressLint +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import ru.tinkoff.acquiring.sdk.R +import ru.tinkoff.acquiring.sdk.redesign.cards.list.models.CardItemUiModel + +class CardsListAdapter : RecyclerView.Adapter() { + + private val cards = mutableListOf() + + @SuppressLint("NotifyDataSetChanged") + fun setCards(cards: List) { + this.cards.clear() + this.cards.addAll(cards) + notifyDataSetChanged() + } + + fun onRemoveCard(id: String) { + //TODO после задачи на удаление карты + } + + fun onAddCard(card: CardItemUiModel) { + //TODO после задачи на добавление карты + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CardViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.acq_card_list_item, parent, false) as View + return CardViewHolder(view) + } + + override fun onBindViewHolder(holder: CardViewHolder, position: Int) { + holder.bind(cards[position]) + } + + override fun getItemCount(): Int { + return cards.size + } + + class CardViewHolder(view: View) : RecyclerView.ViewHolder(view) { + + private val cardNameView = itemView.findViewById(R.id.cardNameMasked) + + fun bind(card: CardItemUiModel) { + cardNameView.text = itemView.context.getString( + R.string.card_list_item_card_name_masked_template, + card.bankName, + card.tale + ) + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/models/CardItemUiModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/models/CardItemUiModel.kt new file mode 100644 index 00000000..80962cdb --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/models/CardItemUiModel.kt @@ -0,0 +1,17 @@ +package ru.tinkoff.acquiring.sdk.redesign.cards.list.models + +import ru.tinkoff.acquiring.sdk.models.Card + +class CardItemUiModel( + val card: Card, + + // TODO after brandByBin algo impl + val bankName: String = "***", + + // TODO after delete card task + val showDelete: Boolean = false +) { + val id = card.cardId + + val tale = card.pan?.takeLast(4) +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt new file mode 100644 index 00000000..445836cd --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt @@ -0,0 +1,78 @@ +package ru.tinkoff.acquiring.sdk.redesign.cards.list.presentation + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import ru.tinkoff.acquiring.sdk.AcquiringSdk +import ru.tinkoff.acquiring.sdk.models.Card +import ru.tinkoff.acquiring.sdk.models.enums.CardStatus +import ru.tinkoff.acquiring.sdk.redesign.cards.list.models.CardItemUiModel +import ru.tinkoff.acquiring.sdk.redesign.cards.list.ui.CardsListState +import ru.tinkoff.acquiring.sdk.responses.GetCardListResponse +import ru.tinkoff.acquiring.sdk.utils.CoroutineManager + +class CardsListViewModel(private val sdk: AcquiringSdk) : ViewModel() { + + private val manager = CoroutineManager() + + private val cardsListFlow = MutableStateFlow?>(null) + + val stateFlow = MutableStateFlow(CardsListState.Loading) + + fun loadData(customerKey: String?, recurrentOnly: Boolean) { + manager.launchOnBackground { + if (customerKey == null) { + handleWithoutCustomerKey() + return@launchOnBackground + } + + sdk.getCardList { this.customerKey = customerKey }.executeFlow().collect { r -> + r.process( + onSuccess = { handleGetCardListResponse(it, recurrentOnly) }, + onFailure = ::handleGetCardListError + ) + } + } + + } + + private fun handleGetCardListResponse(it: GetCardListResponse, recurrentOnly: Boolean) { + try { + val uiCards = filterCards(it.cards, recurrentOnly) + cardsListFlow.tryEmit(uiCards) + if (uiCards.isEmpty()) { + stateFlow.tryEmit(CardsListState.Empty) + } else { + stateFlow.tryEmit(CardsListState.Content(uiCards)) + } + } catch (e: Exception) { + handleGetCardListError(e) + } + } + + private fun filterCards(it: Array, recurrentOnly: Boolean): List { + var activeCards = it.filter { card -> + card.status == CardStatus.ACTIVE + } + + if (recurrentOnly) { + activeCards = activeCards.filter { card -> !card.rebillId.isNullOrBlank() } + } + + return activeCards.map(::CardItemUiModel) + } + + private fun handleGetCardListError(it: Exception) { + cardsListFlow.tryEmit(emptyList()) + stateFlow.tryEmit(CardsListState.Error()) + } + + private fun handleWithoutCustomerKey() { + // уточнить по поводу ошибки + stateFlow.tryEmit(CardsListState.Error()) + } + + override fun onCleared() { + manager.cancelAll() + super.onCleared() + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt new file mode 100644 index 00000000..cfb678e1 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt @@ -0,0 +1,88 @@ +package ru.tinkoff.acquiring.sdk.redesign.cards.list.ui + +import android.os.Bundle +import android.view.ViewGroup +import android.widget.ViewFlipper +import androidx.core.view.children +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import ru.tinkoff.acquiring.sdk.R +import ru.tinkoff.acquiring.sdk.models.options.screen.SavedCardsOptions +import ru.tinkoff.acquiring.sdk.redesign.cards.list.adapters.CardsListAdapter +import ru.tinkoff.acquiring.sdk.redesign.cards.list.presentation.CardsListViewModel +import ru.tinkoff.acquiring.sdk.redesign.common.util.AcqShimmerAnimator +import ru.tinkoff.acquiring.sdk.ui.activities.TransparentActivity +import ru.tinkoff.acquiring.sdk.utils.showById + +internal class CardsListActivity : TransparentActivity() { + + internal lateinit var viewModel: CardsListViewModel + private lateinit var savedCardsOptions: SavedCardsOptions + + private lateinit var cardsListAdapter: CardsListAdapter + private lateinit var viewFlipper: ViewFlipper + private lateinit var cardShimmer: ViewGroup + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + savedCardsOptions = options as SavedCardsOptions + setContentView(R.layout.acq_activity_card_list) + + viewModel = provideViewModel(CardsListViewModel::class.java) as CardsListViewModel + viewModel.loadData(savedCardsOptions.customer.customerKey, options.features.showOnlyRecurrentCards) + + initToolbar() + initViews() + subscribeOnState() + } + + private fun initToolbar() { + setSupportActionBar(findViewById(R.id.acq_toolbar)) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.setDisplayShowHomeEnabled(true) + supportActionBar?.setTitle(R.string.acq_card_list_title) + } + + private fun initViews() { + val recyclerView = findViewById(R.id.acq_card_list_view) + viewFlipper = findViewById(R.id.acq_view_flipper) + cardShimmer = viewFlipper.findViewById(R.id.acq_card_list_shimmer) + cardsListAdapter = CardsListAdapter() + recyclerView.adapter = cardsListAdapter + } + + private fun subscribeOnState() { + lifecycleScope.launch { + viewModel.stateFlow.collectLatest { + when (it) { + is CardsListState.Content -> { + viewFlipper.showById(R.id.acq_card_list_view) + cardsListAdapter.setCards(it.cards) + } + is CardsListState.Loading -> { + viewFlipper.showById(R.id.acq_card_list_shimmer) + AcqShimmerAnimator.animateSequentially( + cardShimmer.children.toList() + ) + } + is CardsListState.Error -> { + viewFlipper.showById(R.id.acq_card_list_stub) + // TODO задача со стабами + } + is CardsListState.Empty -> { + // TODO задача со стабами + } + } + } + } + } + + override fun onBackPressed() { + //TODO навигация по фрагментам флоу управления картой + finish() + } +} + + diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt new file mode 100644 index 00000000..ca13436d --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt @@ -0,0 +1,12 @@ +package ru.tinkoff.acquiring.sdk.redesign.cards.list.ui + +import ru.tinkoff.acquiring.sdk.redesign.cards.list.models.CardItemUiModel + +sealed class CardsListState { + object Loading : CardsListState() + class Content(val cards: List) : CardsListState() + object Empty : CardsListState() + class Error( + //TODO + ) : CardsListState() +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/util/ShimmerAnimation.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/util/ShimmerAnimation.kt new file mode 100644 index 00000000..a7381acd --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/util/ShimmerAnimation.kt @@ -0,0 +1,61 @@ +package ru.tinkoff.acquiring.sdk.redesign.common.util + +import android.view.View +import android.view.animation.AlphaAnimation +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import android.view.animation.CycleInterpolator + +object AcqShimmerAnimator { + + const val FADE_MIN = 0.4f + const val FADE_MAX = 1f + const val FADE_TOTAL_DURATION = 1000L + const val POSITIONED_DELAY = 80L + + fun animateSequentially(views: Iterable, positionedDelay: Long = POSITIONED_DELAY) { + views.forEachIndexed { i, view -> + animatePositioned( + view = view, + position = i, + positionedDelay = positionedDelay + ) + } + } + + fun animateSequentially(vararg views: View) { + views.forEachIndexed { i, view -> animatePositioned(view, i) } + } + + fun animate( + view: View, + delay: Long = 0L, + duration: Long = FADE_TOTAL_DURATION, + fadeMin: Float = FADE_MIN, + fadeMax: Float = FADE_MAX + ) { + AlphaAnimation((fadeMin + fadeMax) / 2, fadeMax).also { + it.duration = duration + it.interpolator = CycleInterpolator(1f) + it.repeatMode = Animation.RESTART + it.repeatCount = Animation.INFINITE + if (delay == 0L) { + view.startAnimation(it) + } else { + it.startTime = AnimationUtils.currentAnimationTimeMillis() + delay + view.animation = it + } + } + } + + fun animatePositioned( + view: View, + position: Int, + duration: Long = FADE_TOTAL_DURATION, + positionedDelay: Long = POSITIONED_DELAY, + fadeMin: Float = FADE_MIN, + fadeMax: Float = FADE_MAX + ) { + animate(view, position * positionedDelay, duration, fadeMin, fadeMax) + } +} diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/PaymentActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/PaymentActivity.kt index cc07e0e7..b2ee55ec 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/PaymentActivity.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/PaymentActivity.kt @@ -77,7 +77,7 @@ internal class PaymentActivity : TransparentActivity() { initViews() if (asdkState is BrowseFpsBankState || asdkState is FpsState || asdkState is OpenTinkoffPayBankState) { - bottomContainer.visibility = View.GONE + bottomContainer?.visibility = View.GONE } paymentViewModel = provideViewModel(PaymentViewModel::class.java) as PaymentViewModel diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/SavedCardsActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/SavedCardsActivity.kt deleted file mode 100644 index 28e83b71..00000000 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/SavedCardsActivity.kt +++ /dev/null @@ -1,383 +0,0 @@ -/* - * Copyright © 2020 Tinkoff Bank - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package ru.tinkoff.acquiring.sdk.ui.activities - -import android.app.Activity -import android.content.Intent -import android.os.Bundle -import android.view.MotionEvent -import android.view.View -import android.widget.ListView -import android.widget.TextView -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.widget.Toolbar -import androidx.lifecycle.Observer -import ru.tinkoff.acquiring.sdk.R -import ru.tinkoff.acquiring.sdk.TinkoffAcquiring -import ru.tinkoff.acquiring.sdk.adapters.CardListAdapter -import ru.tinkoff.acquiring.sdk.localization.AsdkLocalization -import ru.tinkoff.acquiring.sdk.localization.LocalizationResources -import ru.tinkoff.acquiring.sdk.models.Card -import ru.tinkoff.acquiring.sdk.models.ErrorButtonClickedEvent -import ru.tinkoff.acquiring.sdk.models.ErrorScreenState -import ru.tinkoff.acquiring.sdk.models.FinishWithErrorScreenState -import ru.tinkoff.acquiring.sdk.models.ScreenState -import ru.tinkoff.acquiring.sdk.models.SingleEvent -import ru.tinkoff.acquiring.sdk.models.enums.CardStatus -import ru.tinkoff.acquiring.sdk.models.options.screen.AttachCardOptions -import ru.tinkoff.acquiring.sdk.models.options.screen.SavedCardsOptions -import ru.tinkoff.acquiring.sdk.models.result.AsdkResult -import ru.tinkoff.acquiring.sdk.models.result.CardResult -import ru.tinkoff.acquiring.sdk.ui.customview.BottomContainer -import ru.tinkoff.acquiring.sdk.ui.customview.NotificationDialog -import ru.tinkoff.acquiring.sdk.viewmodel.SavedCardsViewModel - -/** - * @author Mariya Chernyadieva - */ -internal class SavedCardsActivity : BaseAcquiringActivity(), CardListAdapter.OnMoreIconClickListener, - CardListAdapter.CardSelectListener { - - private lateinit var deletingBottomContainer: BottomContainer - private lateinit var cardListView: ListView - - private lateinit var savedCardsOptions: SavedCardsOptions - private lateinit var localization: LocalizationResources - private lateinit var cardsAdapter: CardListAdapter - private lateinit var viewModel: SavedCardsViewModel - - private lateinit var customerKey: String - - private var deletingConfirmDialog: AlertDialog? = null - private var notificationDialog: NotificationDialog? = null - - private var isDeletingDialogShowing = false - private var isDeletingBottomContainerShowed = false - private var isErrorOccurred = false - private var isCardListChanged = false - private var deletingCard: Card? = null - private var selectedCardId: String? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - localization = AsdkLocalization.resources - savedCardsOptions = options as SavedCardsOptions - selectedCardId = options.features.selectedCardId - - resolveThemeMode(savedCardsOptions.features.darkThemeMode) - setContentView(R.layout.acq_activity_saved_cards) - - savedInstanceState?.let { - isDeletingDialogShowing = it.getBoolean(STATE_DELETING_DIALOG_SHOWING) - isDeletingBottomContainerShowed = it.getBoolean(STATE_BOTTOM_CONTAINER_SHOWING) - deletingCard = it.getSerializable(STATE_DELETING_CARD) as Card? - selectedCardId = it.getString(STATE_SELECTED_CARD) - } - - initViews() - - viewModel = provideViewModel(SavedCardsViewModel::class.java) as SavedCardsViewModel - observeLiveData() - - if (savedCardsOptions.customer.customerKey != null) { - customerKey = savedCardsOptions.customer.customerKey!! - loadCards() - } else { - showErrorScreen(localization.cardListEmptyList ?: "") - } - - if (isDeletingDialogShowing && deletingCard != null) { - showDeletingConfirmDialog(deletingCard!!) - } - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (requestCode == ATTACH_CARD_REQUEST_CODE) { - if (resultCode == Activity.RESULT_OK) { - loadCards() - isCardListChanged = true - notificationDialog = NotificationDialog(this@SavedCardsActivity).apply { - show() - showSuccess(localization.addCardDialogSuccessCardAdded) - } - } else if (resultCode == TinkoffAcquiring.RESULT_ERROR) { - if (savedCardsOptions.features.handleErrorsInSdk) { - showErrorScreen(localization.payDialogErrorFallbackMessage!!) { - hideErrorScreen() - viewModel.createEvent(ErrorButtonClickedEvent) - } - } else { - finishWithError(data?.getSerializableExtra(TinkoffAcquiring.EXTRA_ERROR) as Throwable) - } - } - } - super.onActivityResult(requestCode, resultCode, data) - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.run { - putBoolean(STATE_DELETING_DIALOG_SHOWING, isDeletingDialogShowing) - putBoolean(STATE_BOTTOM_CONTAINER_SHOWING, isDeletingBottomContainerShowed) - putSerializable(STATE_DELETING_CARD, deletingCard) - putString(STATE_SELECTED_CARD, selectedCardId) - } - } - - override fun onDestroy() { - super.onDestroy() - deletingConfirmDialog?.dismiss() - notificationDialog?.dismiss() - } - - override fun finishWithError(throwable: Throwable) { - isErrorOccurred = true - super.finishWithError(throwable) - } - - override fun finish() { - if (!isErrorOccurred) { - setSuccessResult(CardResult(selectedCardId)) - } - super.finish() - } - - override fun onBackPressed() { - if (deletingBottomContainer.isShowed) { - deletingBottomContainer.hide() - } else { - super.onBackPressed() - } - } - - override fun setSuccessResult(result: AsdkResult) { - val intent = Intent() - - val cardResult = result as CardResult - intent.putExtra(TinkoffAcquiring.EXTRA_CARD_ID, cardResult.cardId) - intent.putExtra(TinkoffAcquiring.EXTRA_CARD_LIST_CHANGED, isCardListChanged) - - setResult(Activity.RESULT_OK, intent) - } - - override fun setErrorResult(throwable: Throwable) { - val intent = Intent() - intent.putExtra(TinkoffAcquiring.EXTRA_ERROR, throwable) - intent.putExtra(TinkoffAcquiring.EXTRA_CARD_ID, selectedCardId) - setResult(TinkoffAcquiring.RESULT_ERROR, intent) - } - - override fun onSupportNavigateUp(): Boolean { - onBackPressed() - return true - } - - override fun onMoreIconClick(card: Card) { - deletingCard = card - deletingBottomContainer.show() - } - - override fun onCardSelected(card: Card) { - selectedCardId = card.cardId - } - - override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { - ev ?: return false - return if (isDeletingBottomContainerShowed) { - - if (deletingBottomContainer.containsRect(ev.x.toInt(), ev.y.toInt())) { - super.dispatchTouchEvent(ev) - } else { - deletingBottomContainer.hide() - false - } - - } else { - super.dispatchTouchEvent(ev) - } - } - - private fun initViews() { - val toolbar = findViewById(R.id.acq_toolbar) - toolbar.title = localization.cardListTitle - setSupportActionBar(toolbar) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - supportActionBar?.setDisplayShowHomeEnabled(true) - - deletingBottomContainer = findViewById(R.id.acq_bottom_container) - deletingBottomContainer.run { - showInitAnimation = false - containerState = if (isDeletingBottomContainerShowed) { - BottomContainer.STATE_SHOWED - } else { - BottomContainer.STATE_HIDDEN - } - setContainerStateListener(object : BottomContainer.ContainerStateListener { - override fun onHidden() { - isDeletingBottomContainerShowed = false - } - - override fun onShowed() { - isDeletingBottomContainerShowed = true - } - - override fun onFullscreenOpened() = Unit - }) - } - - val addCardTextView = findViewById(R.id.acq_add_card) - if (options.features.showOnlyRecurrentCards) { - addCardTextView.visibility = View.GONE - } else { - addCardTextView.text = localization.addCardAttachmentTitle - addCardTextView.setOnClickListener { - openAttachActivity() - } - } - - val deleteCardTextView = findViewById(R.id.acq_delete_card) - deleteCardTextView.text = localization.cardListDeleteCard - deleteCardTextView.setOnClickListener { - showDeletingConfirmDialog(deletingCard!!) - } - - val selectTitle = findViewById(R.id.acq_select_card_title) - selectTitle.text = localization.cardListSelectCard - selectTitle.visibility = if (options.features.userCanSelectCard && selectTitle.text.isNotBlank()) { - View.VISIBLE - } else { - View.GONE - } - - cardListView = findViewById(R.id.acq_card_list) - cardsAdapter = CardListAdapter(this).apply { - moreClickListener = this@SavedCardsActivity - if (options.features.userCanSelectCard) { - cardSelectListener = this@SavedCardsActivity - } - } - cardListView.adapter = cardsAdapter - } - - private fun openAttachActivity() { - val options = AttachCardOptions().setOptions { - setTerminalParams(savedCardsOptions.terminalKey, savedCardsOptions.publicKey) - customerOptions { - checkType = savedCardsOptions.customer.checkType - customerKey = savedCardsOptions.customer.customerKey - } - features = savedCardsOptions.features - } - val intent = createIntent(this, options, AttachCardActivity::class.java) - startActivityForResult(intent, ATTACH_CARD_REQUEST_CODE) - } - - private fun observeLiveData() { - with(viewModel) { - loadStateLiveData.observe(this@SavedCardsActivity, Observer { handleLoadState(it) }) - screenStateLiveData.observe(this@SavedCardsActivity, Observer { handleScreenState(it) }) - cardsResultLiveData.observe(this@SavedCardsActivity, Observer { handleCards(it) }) - deleteCardEventLiveData.observe(this@SavedCardsActivity, Observer { handleDeleteCardEvent(it) }) - } - } - - private fun handleCards(cardsList: List) { - if (cardsList.isNotEmpty()) { - hideErrorScreen() - cardsAdapter.setCards(cardsList) - selectedCardId?.let { cardId -> - if (cardsList.any { it.cardId == cardId }) { - cardsAdapter.setSelectedCard(cardId) - } else { - selectedCardId = null - } - } - } else { - showErrorScreen(localization.cardListEmptyList - ?: "", localization.addCardAttachmentTitle) { - openAttachActivity() - } - } - } - - private fun handleDeleteCardEvent(event: SingleEvent) { - event.getValueIfNotHandled()?.let { - loadCards() - isCardListChanged = true - notificationDialog = NotificationDialog(this@SavedCardsActivity).apply { - show() - showSuccess(String.format(localization.addCardDialogSuccessCardDeleted!!, - cardsAdapter.getLastPanNumbers(deletingCard!!.pan!!))) - } - } - } - - private fun loadCards() { - viewModel.getCardList(customerKey, options.features.showOnlyRecurrentCards) - } - - private fun handleScreenState(screenState: ScreenState) { - when (screenState) { - is ErrorButtonClickedEvent -> loadCards() - is FinishWithErrorScreenState -> finishWithError(screenState.error) - is ErrorScreenState -> { - if (screenState.message == localization.cardListEmptyList ?: "") { - showErrorScreen(screenState.message) - } else { - showErrorScreen(screenState.message) { - hideErrorScreen() - viewModel.createEvent(ErrorButtonClickedEvent) - } - } - } - else -> Unit - } - } - - private fun showDeletingConfirmDialog(card: Card) { - deletingConfirmDialog = AlertDialog.Builder(this).apply { - setTitle(String.format(localization.cardListDialogDeleteTitleFormat!!, cardsAdapter.getLastPanNumbers(card.pan!!))) - setMessage(localization.cardListDialogDeleteMessage) - setPositiveButton(localization.cardListDelete) { dialog, _ -> - dialog.dismiss() - viewModel.deleteCard(card.cardId!!, customerKey) - deletingBottomContainer.hide() - isDeletingDialogShowing = false - } - setNegativeButton(localization.commonCancel) { dialog, _ -> - dialog.dismiss() - deletingBottomContainer.hide() - isDeletingDialogShowing = false - } - setOnCancelListener { - isDeletingDialogShowing = false - } - }.show() - isDeletingDialogShowing = true - } - - companion object { - - private const val STATE_DELETING_DIALOG_SHOWING = "state_dialog" - private const val STATE_BOTTOM_CONTAINER_SHOWING = "state_bottom_container" - private const val STATE_DELETING_CARD = "state_card" - private const val STATE_SELECTED_CARD = "state_selected_card" - - private const val ATTACH_CARD_REQUEST_CODE = 50 - } -} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/TransparentActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/TransparentActivity.kt index 689070c6..a51428fb 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/TransparentActivity.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/TransparentActivity.kt @@ -48,7 +48,7 @@ import ru.tinkoff.acquiring.sdk.viewmodel.ThreeDsViewModel */ internal open class TransparentActivity : BaseAcquiringActivity() { - protected lateinit var bottomContainer: BottomContainer + protected var bottomContainer: BottomContainer? = null private lateinit var localization: LocalizationResources private var showBottomView = true @@ -127,7 +127,7 @@ internal open class TransparentActivity : BaseAcquiringActivity() { } override fun onBackPressed() { - if (bottomContainer.isEnabled) { + if (bottomContainer?.isEnabled == true) { closeActivity() } } @@ -144,7 +144,7 @@ internal open class TransparentActivity : BaseAcquiringActivity() { override fun handleLoadState(loadState: LoadState) { super.handleLoadState(loadState) - bottomContainer.isEnabled = loadState is LoadedState + bottomContainer?.isEnabled = loadState is LoadedState } protected fun initViews(fullScreenMode: Boolean = false) { @@ -166,7 +166,7 @@ internal open class TransparentActivity : BaseAcquiringActivity() { setContentView(R.layout.acq_activity) bottomContainer = findViewById(R.id.acq_activity_bottom_container) - bottomContainer.setContainerStateListener(object : BottomContainer.ContainerStateListener { + bottomContainer?.setContainerStateListener(object : BottomContainer.ContainerStateListener { override fun onHidden() { finish() overridePendingTransition(0, 0) @@ -191,12 +191,12 @@ internal open class TransparentActivity : BaseAcquiringActivity() { } } - bottomContainer.containerState = if ((viewType == EXPANDED_INDEX && !fullScreenMode) && orientation == Configuration.ORIENTATION_PORTRAIT) { + bottomContainer?.containerState = if ((viewType == EXPANDED_INDEX && !fullScreenMode) && orientation == Configuration.ORIENTATION_PORTRAIT) { BottomContainer.STATE_SHOWED } else { BottomContainer.STATE_FULLSCREEN } - bottomContainer.showInitAnimation = showBottomView + bottomContainer?.showInitAnimation = showBottomView } private fun setupTranslucentStatusBar() { @@ -217,8 +217,8 @@ internal open class TransparentActivity : BaseAcquiringActivity() { } private fun closeActivity() { - if (viewType == EXPANDED_INDEX && bottomContainer.containerState != BottomContainer.STATE_FULLSCREEN) { - bottomContainer.hide() + if (viewType == EXPANDED_INDEX && bottomContainer?.containerState != BottomContainer.STATE_FULLSCREEN) { + bottomContainer?.hide() } else { finish() } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/fragments/PaymentFragment.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/fragments/PaymentFragment.kt index 33ad7239..ef445fd5 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/fragments/PaymentFragment.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/fragments/PaymentFragment.kt @@ -65,7 +65,6 @@ import ru.tinkoff.acquiring.sdk.models.options.screen.SavedCardsOptions import ru.tinkoff.acquiring.sdk.models.paysources.CardData import ru.tinkoff.acquiring.sdk.responses.TinkoffPayStatusResponse import ru.tinkoff.acquiring.sdk.ui.activities.BaseAcquiringActivity -import ru.tinkoff.acquiring.sdk.ui.activities.SavedCardsActivity import ru.tinkoff.acquiring.sdk.ui.customview.editcard.EditCardScanButtonClickListener import ru.tinkoff.acquiring.sdk.ui.customview.scrollingindicator.ScrollingPagerIndicator import ru.tinkoff.acquiring.sdk.viewmodel.PaymentViewModel diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt index eb85a495..6c9292d8 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt @@ -34,6 +34,8 @@ import kotlin.coroutines.suspendCoroutine */ internal class CoroutineManager(private val exceptionHandler: (Throwable) -> Unit) { + constructor() : this({}) + private val job = SupervisorJob() private val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable -> launchOnMain { exceptionHandler(throwable) } } private val coroutineScope = CoroutineScope(Dispatchers.Main + coroutineExceptionHandler + job) diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/FlipperExt.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/FlipperExt.kt new file mode 100644 index 00000000..b0476361 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/FlipperExt.kt @@ -0,0 +1,11 @@ +package ru.tinkoff.acquiring.sdk.utils + +import android.widget.ViewFlipper +import androidx.core.view.children + +/** + * Created by Ivan Golovachev + */ +fun ViewFlipper.showById(id: Int) { + displayedChild = children.indexOfFirst { it.id == id } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/SavedCardsViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/SavedCardsViewModel.kt deleted file mode 100644 index 96f443a6..00000000 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/SavedCardsViewModel.kt +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright © 2020 Tinkoff Bank - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package ru.tinkoff.acquiring.sdk.viewmodel - -import android.app.Application -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import ru.tinkoff.acquiring.sdk.AcquiringSdk -import ru.tinkoff.acquiring.sdk.exceptions.AcquiringApiException -import ru.tinkoff.acquiring.sdk.localization.AsdkLocalization -import ru.tinkoff.acquiring.sdk.models.* -import ru.tinkoff.acquiring.sdk.models.LoadedState -import ru.tinkoff.acquiring.sdk.models.LoadingState -import ru.tinkoff.acquiring.sdk.models.enums.CardStatus -import ru.tinkoff.acquiring.sdk.network.AcquiringApi - -/** - * @author Mariya Chernyadieva - */ -internal class SavedCardsViewModel( - application: Application, - handleErrorsInSdk: Boolean, - sdk: AcquiringSdk -) : BaseAcquiringViewModel(application, handleErrorsInSdk, sdk) { - - private val needHandleErrorsInSdk = handleErrorsInSdk - private val deleteCardEvent: MutableLiveData> = MutableLiveData() - private var cardsResult: MutableLiveData> = MutableLiveData() - - val deleteCardEventLiveData: LiveData> = deleteCardEvent - val cardsResultLiveData: LiveData> = cardsResult - - fun getCardList(customerKey: String, recurrentOnly: Boolean) { - changeScreenState(DefaultScreenState) - changeScreenState(LoadingState) - - val request = sdk.getCardList { - this.customerKey = customerKey - } - - coroutine.call(request, - onSuccess = { - var activeCards = it.cards.filter { card -> - card.status == CardStatus.ACTIVE - } - if (recurrentOnly) { - activeCards = activeCards.filter { card -> !card.rebillId.isNullOrBlank() } - } - cardsResult.value = activeCards - changeScreenState(LoadedState) - }, - onFailure = { - val apiError = it as? AcquiringApiException - if (needHandleErrorsInSdk && apiError != null && it.response != null && - it.response!!.errorCode == AcquiringApi.API_ERROR_CODE_CUSTOMER_NOT_FOUND) { - changeScreenState(LoadedState) - changeScreenState(ErrorScreenState(AsdkLocalization.resources.cardListEmptyList ?: "")) - } else { - handleException(it) - } - }) - } - - fun deleteCard(cardId: String, customerKey: String) { - val request = sdk.removeCard { - this.cardId = cardId - this.customerKey = customerKey - } - - coroutine.call(request, - onSuccess = { response -> - when (response.status) { - CardStatus.DELETED -> { - deleteCardEvent.value = SingleEvent(response.status!!) - } - else -> { - changeScreenState(ErrorScreenState(AsdkLocalization.resources.payDialogErrorFallbackMessage!!)) - } - } - changeScreenState(LoadedState) - }) - } -} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/ViewModelProviderFactory.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/ViewModelProviderFactory.kt index 1e6292d2..9339cb84 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/ViewModelProviderFactory.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/ViewModelProviderFactory.kt @@ -20,6 +20,7 @@ import android.app.Application import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import ru.tinkoff.acquiring.sdk.AcquiringSdk +import ru.tinkoff.acquiring.sdk.redesign.cards.list.presentation.CardsListViewModel /** * @author Mariya Chernyadieva @@ -29,16 +30,28 @@ internal class ViewModelProviderFactory( ) : ViewModelProvider.AndroidViewModelFactory(application) { private val viewModelCollection: Map, BaseAcquiringViewModel> = mapOf( - BaseAcquiringViewModel::class.java to BaseAcquiringViewModel(application, handleErrorsInSdk, sdk), + BaseAcquiringViewModel::class.java to BaseAcquiringViewModel( + application, + handleErrorsInSdk, + sdk + ), PaymentViewModel::class.java to PaymentViewModel(application, handleErrorsInSdk, sdk), AttachCardViewModel::class.java to AttachCardViewModel(application, handleErrorsInSdk, sdk), QrViewModel::class.java to QrViewModel(application, handleErrorsInSdk, sdk), ThreeDsViewModel::class.java to ThreeDsViewModel(application, handleErrorsInSdk, sdk), - SavedCardsViewModel::class.java to SavedCardsViewModel(application, handleErrorsInSdk, sdk), - NotificationPaymentViewModel::class.java to NotificationPaymentViewModel(application, handleErrorsInSdk, sdk)) + NotificationPaymentViewModel::class.java to NotificationPaymentViewModel( + application, + handleErrorsInSdk, + sdk + ) + ) + + private val redesignViewModels = mapOf, ViewModel>( + CardsListViewModel::class.java to CardsListViewModel(sdk) + ) @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { - return viewModelCollection[modelClass] as T + return (redesignViewModels + viewModelCollection)[modelClass] as T } } \ No newline at end of file diff --git a/ui/src/main/res/drawable/acq_shimmer_rectangle_bg.xml b/ui/src/main/res/drawable/acq_shimmer_rectangle_bg.xml new file mode 100644 index 00000000..5dc0baa8 --- /dev/null +++ b/ui/src/main/res/drawable/acq_shimmer_rectangle_bg.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/ui/src/main/res/drawable/tui_avatar.xml b/ui/src/main/res/drawable/tui_avatar.xml new file mode 100644 index 00000000..ab3a83c1 --- /dev/null +++ b/ui/src/main/res/drawable/tui_avatar.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_activity_card_list.xml b/ui/src/main/res/layout/acq_activity_card_list.xml new file mode 100644 index 00000000..39ab2a15 --- /dev/null +++ b/ui/src/main/res/layout/acq_activity_card_list.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_base_card_activity.xml b/ui/src/main/res/layout/acq_base_card_activity.xml new file mode 100644 index 00000000..6b47e5da --- /dev/null +++ b/ui/src/main/res/layout/acq_base_card_activity.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/layout/acq_card_list_card_item_shimmer.xml b/ui/src/main/res/layout/acq_card_list_card_item_shimmer.xml new file mode 100644 index 00000000..83b03a6b --- /dev/null +++ b/ui/src/main/res/layout/acq_card_list_card_item_shimmer.xml @@ -0,0 +1,34 @@ + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_card_list_content.xml b/ui/src/main/res/layout/acq_card_list_content.xml new file mode 100644 index 00000000..79e4c329 --- /dev/null +++ b/ui/src/main/res/layout/acq_card_list_content.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_card_list_empty_stub.xml b/ui/src/main/res/layout/acq_card_list_empty_stub.xml new file mode 100644 index 00000000..3286901f --- /dev/null +++ b/ui/src/main/res/layout/acq_card_list_empty_stub.xml @@ -0,0 +1,28 @@ + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_card_list_error_stub.xml b/ui/src/main/res/layout/acq_card_list_error_stub.xml new file mode 100644 index 00000000..b4d42097 --- /dev/null +++ b/ui/src/main/res/layout/acq_card_list_error_stub.xml @@ -0,0 +1,27 @@ + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_card_list_item.xml b/ui/src/main/res/layout/acq_card_list_item.xml new file mode 100644 index 00000000..ae1e5280 --- /dev/null +++ b/ui/src/main/res/layout/acq_card_list_item.xml @@ -0,0 +1,38 @@ + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_card_list_shimmer.xml b/ui/src/main/res/layout/acq_card_list_shimmer.xml new file mode 100644 index 00000000..5a7c5c5d --- /dev/null +++ b/ui/src/main/res/layout/acq_card_list_shimmer.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/values-night/colors.xml b/ui/src/main/res/values-night/colors.xml index 1fbfbb43..21663428 100644 --- a/ui/src/main/res/values-night/colors.xml +++ b/ui/src/main/res/values-night/colors.xml @@ -29,4 +29,6 @@ #727272 @color/acq_colorMain + + #333335 \ No newline at end of file diff --git a/ui/src/main/res/values-ru/strings.xml b/ui/src/main/res/values-ru/strings.xml index ea027a84..2e08b018 100644 --- a/ui/src/main/res/values-ru/strings.xml +++ b/ui/src/main/res/values-ru/strings.xml @@ -24,4 +24,6 @@ Выберите приложение + %1$s • %2$s + Ваши карты \ No newline at end of file diff --git a/ui/src/main/res/values/colors.xml b/ui/src/main/res/values/colors.xml index 68240d0f..38fbf249 100644 --- a/ui/src/main/res/values/colors.xml +++ b/ui/src/main/res/values/colors.xml @@ -46,4 +46,6 @@ #FFFFFF #2CFFFFFF @color/acq_colorBrandGray + + #f7f7f7 diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index e09d5127..2e226250 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -26,4 +26,6 @@ fps Tinkoff + %1$s • %2$s + Your cards diff --git a/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsBaseViewModelTest.kt b/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsBaseViewModelTest.kt new file mode 100644 index 00000000..889253fe --- /dev/null +++ b/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsBaseViewModelTest.kt @@ -0,0 +1,117 @@ +package ru.tinkoff.acquiring.sdk.redesign.cards.list + +import app.cash.turbine.test +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import org.junit.Assert.* +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import ru.tinkoff.acquiring.sdk.AcquiringSdk +import ru.tinkoff.acquiring.sdk.models.enums.CardStatus +import ru.tinkoff.acquiring.sdk.redesign.cards.list.presentation.CardsListViewModel +import ru.tinkoff.acquiring.sdk.redesign.cards.list.ui.CardsListState +import ru.tinkoff.acquiring.sdk.requests.GetCardListRequest +import ru.tinkoff.acquiring.sdk.responses.GetCardListResponse +import ru.tinkoff.acquiring.sdk.utils.RequestResult + +class CardsListViewModelTest { + + @Test + fun testWithoutKey() { + runBlocking { + val viewModel = createViewModelMock(mock()) + viewModel.loadData(null, false) + viewModel.stateFlow.test { + val awaitLoading = awaitItem() + assertTrue( + "${awaitLoading.javaClass.simpleName} is not Loading", + awaitLoading is CardsListState.Error + ) + } + } + } + + @Test + fun `when card list empty`() { + runViewModelCardsLoadTest( + "key", + RequestResult.Success( + GetCardListResponse( + emptyArray() + ) + ) + ) + } + + @Test + fun `when card list full`() { + runViewModelCardsLoadTest( + "key", + RequestResult.Success( + GetCardListResponse( + arrayOf(mock { + on { pan } doReturn "3413413413413414" + on { cardId } doReturn "1" + on { status } doReturn CardStatus.ACTIVE + }) + ) + ) + ) + } + + @Test + fun `when card list only D status full`() { + runViewModelCardsLoadTest( + "key", + RequestResult.Success( + GetCardListResponse( + arrayOf(mock { + on { pan } doReturn "3413413413413414" + on { cardId } doReturn "1" + on { status } doReturn CardStatus.DELETED + }) + ) + ) + ) + } + + + private inline fun runViewModelCardsLoadTest( + key: String?, + requestResult: RequestResult, + recurrentOnly: Boolean = false, + ) { + runBlocking { + val viewModel = createViewModelMock(requestResult) + viewModel.loadData(key, recurrentOnly) + viewModel.stateFlow.test { + val awaitLoading = awaitItem() + assertTrue( + "${awaitLoading.javaClass.simpleName} is not Loading", + awaitLoading is CardsListState.Loading + ) + val awaitNextEvent = awaitItem() + val excClass = T::class + assertTrue( + "${awaitNextEvent.javaClass.simpleName} is not ${excClass.simpleName}", + awaitNextEvent::class == excClass + ) + } + } + } + + private fun createViewModelMock( + result: RequestResult + ): CardsListViewModel { + val request = mock { + on { executeFlow() } doReturn MutableStateFlow(result) + } + val sdk = mock { + on { getCardList(any()) } doReturn request + } + return CardsListViewModel(sdk) + } + +} \ No newline at end of file From 92b03f199354aaee8df21ecf9f6a93bf84d9de38 Mon Sep 17 00:00:00 2001 From: jqwout Date: Tue, 8 Nov 2022 10:33:58 +0300 Subject: [PATCH 003/126] =?UTF-8?q?=D0=A0=D0=B5=D0=B4=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D0=B9=D0=BD.=D0=A3=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BA=D0=B0=D1=80=D1=82=D0=B0=D0=BC=D0=B8.=20?= =?UTF-8?q?=D0=AD=D0=BA=D1=80=D0=B0=D0=BD=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BA=D0=B0=D1=80=D1=82.=20=D0=97=D0=B0=D0=B3=D0=BB?= =?UTF-8?q?=D1=83=D1=88=D0=BA=D0=B8.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../acquiring/sample/ui/MainActivity.kt | 2 +- ui/src/main/AndroidManifest.xml | 7 +- .../list/presentation/CardsListViewModel.kt | 16 +- .../cards/list/ui/CardsListActivity.kt | 71 +++++- .../redesign/cards/list/ui/CardsListState.kt | 5 +- .../sdk/ui/fragments/PaymentFragment.kt | 3 +- .../acquiring/sdk/utils/ConnectionChecker.kt | 32 +++ .../sdk/viewmodel/ViewModelProviderFactory.kt | 3 +- .../acq_ic_cards_list_empty.xml | 83 +++++++ .../acq_ic_cards_list_error_stub.xml | 134 +++++++++++ .../res/drawable-night/acq_ic_no_network.xml | 217 ++++++++++++++++++ .../acq_button_retry_bg.xml} | 19 +- .../res/drawable/acq_ic_cards_list_empty.xml | 91 ++++++++ .../drawable/acq_ic_cards_list_error_stub.xml | 135 +++++++++++ .../main/res/drawable/acq_ic_no_network.xml | 217 ++++++++++++++++++ ui/src/main/res/drawable/tui_avatar.xml | 14 -- ui/src/main/res/drawable/tui_avatar__1_.xml | 28 +++ .../res/layout/acq_activity_card_list.xml | 2 +- .../main/res/layout/acq_card_list_content.xml | 12 +- .../res/layout/acq_card_list_empty_stub.xml | 28 --- ui/src/main/res/layout/acq_card_list_stub.xml | 77 +++++++ ui/src/main/res/values-ru/strings.xml | 8 + ui/src/main/res/values/strings.xml | 11 + ui/src/main/res/values/styles.xml | 5 + 24 files changed, 1134 insertions(+), 86 deletions(-) create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/ConnectionChecker.kt create mode 100644 ui/src/main/res/drawable-night/acq_ic_cards_list_empty.xml create mode 100644 ui/src/main/res/drawable-night/acq_ic_cards_list_error_stub.xml create mode 100644 ui/src/main/res/drawable-night/acq_ic_no_network.xml rename ui/src/main/res/{layout/acq_card_list_error_stub.xml => drawable/acq_button_retry_bg.xml} (59%) create mode 100644 ui/src/main/res/drawable/acq_ic_cards_list_empty.xml create mode 100644 ui/src/main/res/drawable/acq_ic_cards_list_error_stub.xml create mode 100644 ui/src/main/res/drawable/acq_ic_no_network.xml delete mode 100644 ui/src/main/res/drawable/tui_avatar.xml create mode 100644 ui/src/main/res/drawable/tui_avatar__1_.xml delete mode 100644 ui/src/main/res/layout/acq_card_list_empty_stub.xml create mode 100644 ui/src/main/res/layout/acq_card_list_stub.xml diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt index efcf23d7..b817dbe4 100644 --- a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt @@ -269,7 +269,7 @@ class MainActivity : AppCompatActivity(), BooksListAdapter.BookDetailsClickListe } } - SampleApplication.tinkoffAcquiring.openSavedCardsScreenV2(this, + SampleApplication.tinkoffAcquiring.openSavedCardsScreen(this, options, SAVED_CARDS_REQUEST_CODE) } diff --git a/ui/src/main/AndroidManifest.xml b/ui/src/main/AndroidManifest.xml index f41daa61..1f0cea68 100644 --- a/ui/src/main/AndroidManifest.xml +++ b/ui/src/main/AndroidManifest.xml @@ -32,6 +32,8 @@ + + @@ -58,11 +60,6 @@ android:screenOrientation="unspecified" android:theme="@style/AcquiringTheme" /> - - (CardsListState.Loading) fun loadData(customerKey: String?, recurrentOnly: Boolean) { + if (connectionChecker.isOnline().not()) { + stateFlow.tryEmit(CardsListState.NoNetwork) + return + } + stateFlow.tryEmit(CardsListState.Loading) manager.launchOnBackground { if (customerKey == null) { handleWithoutCustomerKey() @@ -63,12 +72,11 @@ class CardsListViewModel(private val sdk: AcquiringSdk) : ViewModel() { private fun handleGetCardListError(it: Exception) { cardsListFlow.tryEmit(emptyList()) - stateFlow.tryEmit(CardsListState.Error()) + stateFlow.tryEmit(CardsListState.Error) } private fun handleWithoutCustomerKey() { - // уточнить по поводу ошибки - stateFlow.tryEmit(CardsListState.Error()) + stateFlow.tryEmit(CardsListState.Error) } override fun onCleared() { diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt index cfb678e1..99c16a2f 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt @@ -1,7 +1,10 @@ package ru.tinkoff.acquiring.sdk.redesign.cards.list.ui import android.os.Bundle +import android.view.View import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView import android.widget.ViewFlipper import androidx.core.view.children import androidx.lifecycle.lifecycleScope @@ -25,13 +28,29 @@ internal class CardsListActivity : TransparentActivity() { private lateinit var viewFlipper: ViewFlipper private lateinit var cardShimmer: ViewGroup + private val stubImage: ImageView by lazy(LazyThreadSafetyMode.NONE) { + findViewById(R.id.acq_stub_img) + } + private val stubTitleView: TextView by lazy(LazyThreadSafetyMode.NONE) { + findViewById(R.id.acq_stub_title) + } + private val stubSubtitleView: TextView by lazy(LazyThreadSafetyMode.NONE) { + findViewById(R.id.acq_stub_subtitle) + } + private val stubButtonView: TextView by lazy(LazyThreadSafetyMode.NONE) { + findViewById(R.id.acq_stub_retry_button) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) savedCardsOptions = options as SavedCardsOptions setContentView(R.layout.acq_activity_card_list) viewModel = provideViewModel(CardsListViewModel::class.java) as CardsListViewModel - viewModel.loadData(savedCardsOptions.customer.customerKey, options.features.showOnlyRecurrentCards) + viewModel.loadData( + savedCardsOptions.customer.customerKey, + options.features.showOnlyRecurrentCards + ) initToolbar() initViews() @@ -68,11 +87,28 @@ internal class CardsListActivity : TransparentActivity() { ) } is CardsListState.Error -> { - viewFlipper.showById(R.id.acq_card_list_stub) - // TODO задача со стабами + showStub( + imageResId = R.drawable.acq_ic_cards_list_error_stub, + titleTextRes = R.string.acq_cards_list_error_title, + subTitleTextRes = R.string.acq_cards_list_error_subtitle, + buttonTextRes = R.string.acq_cards_list_error_button + ) } is CardsListState.Empty -> { - // TODO задача со стабами + showStub( + imageResId = R.drawable.acq_ic_cards_list_empty, + titleTextRes = null, + subTitleTextRes = R.string.acq_cards_list_empty_subtitle, + buttonTextRes = R.string.acq_cards_list_empty_button + ) + } + is CardsListState.NoNetwork -> { + showStub( + imageResId = R.drawable.acq_ic_no_network, + titleTextRes = R.string.acq_cards_list_no_network_title, + subTitleTextRes = R.string.acq_cards_list_no_network_subtitle, + buttonTextRes = R.string.acq_cards_list_no_network_button + ) } } } @@ -80,9 +116,34 @@ internal class CardsListActivity : TransparentActivity() { } override fun onBackPressed() { - //TODO навигация по фрагментам флоу управления картой finish() } + + private fun showStub( + imageResId: Int, + titleTextRes: Int?, + subTitleTextRes: Int, + buttonTextRes: Int + ) { + viewFlipper.showById(R.id.acq_card_list_stub) + + stubImage.setImageResource(imageResId) + if (titleTextRes == null) { + stubTitleView.visibility = View.GONE + } else { + stubTitleView.setText(titleTextRes) + stubTitleView.visibility = View.VISIBLE + } + stubSubtitleView.setText(subTitleTextRes) + stubButtonView.setText(buttonTextRes) + + stubButtonView.setOnClickListener { + viewModel.loadData( + savedCardsOptions.customer.customerKey, + options.features.showOnlyRecurrentCards + ) + } + } } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt index ca13436d..6a3299ac 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt @@ -6,7 +6,6 @@ sealed class CardsListState { object Loading : CardsListState() class Content(val cards: List) : CardsListState() object Empty : CardsListState() - class Error( - //TODO - ) : CardsListState() + object Error : CardsListState() + object NoNetwork : CardsListState() } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/fragments/PaymentFragment.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/fragments/PaymentFragment.kt index ef445fd5..54ecd94e 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/fragments/PaymentFragment.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/fragments/PaymentFragment.kt @@ -63,6 +63,7 @@ import ru.tinkoff.acquiring.sdk.models.SelectCardAndPayState import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions import ru.tinkoff.acquiring.sdk.models.options.screen.SavedCardsOptions import ru.tinkoff.acquiring.sdk.models.paysources.CardData +import ru.tinkoff.acquiring.sdk.redesign.cards.list.ui.CardsListActivity import ru.tinkoff.acquiring.sdk.responses.TinkoffPayStatusResponse import ru.tinkoff.acquiring.sdk.ui.activities.BaseAcquiringActivity import ru.tinkoff.acquiring.sdk.ui.customview.editcard.EditCardScanButtonClickListener @@ -163,7 +164,7 @@ internal class PaymentFragment : BaseAcquiringFragment(), EditCardScanButtonClic val options = getSavedCardOptions() val intent = BaseAcquiringActivity.createIntent(requireActivity(), options, - SavedCardsActivity::class.java) + CardsListActivity::class.java) startActivityForResult(intent, CARD_LIST_REQUEST_CODE) } }) diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/ConnectionChecker.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/ConnectionChecker.kt new file mode 100644 index 00000000..057d0c6a --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/ConnectionChecker.kt @@ -0,0 +1,32 @@ +package ru.tinkoff.acquiring.sdk.utils + +import android.app.Application +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build + +/** + * Created by Your name + */ +class ConnectionChecker(val application: Application) { + + fun isOnline(): Boolean { + val connectivityManager = application.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val network = connectivityManager.activeNetwork ?: return false + val activeNetwork = connectivityManager.getNetworkCapabilities(network) ?: return false + return when { + activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true + activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true + else -> false + } + } else { + // if the android version is below M + @Suppress("DEPRECATION") + val networkInfo = connectivityManager.activeNetworkInfo ?: return false + @Suppress("DEPRECATION") + return networkInfo.isConnected + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/ViewModelProviderFactory.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/ViewModelProviderFactory.kt index 9339cb84..ec06bb84 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/ViewModelProviderFactory.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/ViewModelProviderFactory.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import ru.tinkoff.acquiring.sdk.AcquiringSdk import ru.tinkoff.acquiring.sdk.redesign.cards.list.presentation.CardsListViewModel +import ru.tinkoff.acquiring.sdk.utils.ConnectionChecker /** * @author Mariya Chernyadieva @@ -47,7 +48,7 @@ internal class ViewModelProviderFactory( ) private val redesignViewModels = mapOf, ViewModel>( - CardsListViewModel::class.java to CardsListViewModel(sdk) + CardsListViewModel::class.java to CardsListViewModel(sdk, ConnectionChecker(application)) ) @Suppress("UNCHECKED_CAST") diff --git a/ui/src/main/res/drawable-night/acq_ic_cards_list_empty.xml b/ui/src/main/res/drawable-night/acq_ic_cards_list_empty.xml new file mode 100644 index 00000000..99328576 --- /dev/null +++ b/ui/src/main/res/drawable-night/acq_ic_cards_list_empty.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/drawable-night/acq_ic_cards_list_error_stub.xml b/ui/src/main/res/drawable-night/acq_ic_cards_list_error_stub.xml new file mode 100644 index 00000000..705ae5b2 --- /dev/null +++ b/ui/src/main/res/drawable-night/acq_ic_cards_list_error_stub.xml @@ -0,0 +1,134 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/drawable-night/acq_ic_no_network.xml b/ui/src/main/res/drawable-night/acq_ic_no_network.xml new file mode 100644 index 00000000..a10b278d --- /dev/null +++ b/ui/src/main/res/drawable-night/acq_ic_no_network.xml @@ -0,0 +1,217 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/layout/acq_card_list_error_stub.xml b/ui/src/main/res/drawable/acq_button_retry_bg.xml similarity index 59% rename from ui/src/main/res/layout/acq_card_list_error_stub.xml rename to ui/src/main/res/drawable/acq_button_retry_bg.xml index b4d42097..33a9a949 100644 --- a/ui/src/main/res/layout/acq_card_list_error_stub.xml +++ b/ui/src/main/res/drawable/acq_button_retry_bg.xml @@ -1,4 +1,5 @@ - - - - - - \ No newline at end of file + + + + \ No newline at end of file diff --git a/ui/src/main/res/drawable/acq_ic_cards_list_empty.xml b/ui/src/main/res/drawable/acq_ic_cards_list_empty.xml new file mode 100644 index 00000000..74c7a235 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_cards_list_empty.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_cards_list_error_stub.xml b/ui/src/main/res/drawable/acq_ic_cards_list_error_stub.xml new file mode 100644 index 00000000..50767f09 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_cards_list_error_stub.xml @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_no_network.xml b/ui/src/main/res/drawable/acq_ic_no_network.xml new file mode 100644 index 00000000..d8c94ec2 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_no_network.xml @@ -0,0 +1,217 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/tui_avatar.xml b/ui/src/main/res/drawable/tui_avatar.xml deleted file mode 100644 index ab3a83c1..00000000 --- a/ui/src/main/res/drawable/tui_avatar.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/ui/src/main/res/drawable/tui_avatar__1_.xml b/ui/src/main/res/drawable/tui_avatar__1_.xml new file mode 100644 index 00000000..dcfdb8b3 --- /dev/null +++ b/ui/src/main/res/drawable/tui_avatar__1_.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + diff --git a/ui/src/main/res/layout/acq_activity_card_list.xml b/ui/src/main/res/layout/acq_activity_card_list.xml index 39ab2a15..f745ecd5 100644 --- a/ui/src/main/res/layout/acq_activity_card_list.xml +++ b/ui/src/main/res/layout/acq_activity_card_list.xml @@ -48,7 +48,7 @@ + layout="@layout/acq_card_list_stub" /> diff --git a/ui/src/main/res/layout/acq_card_list_content.xml b/ui/src/main/res/layout/acq_card_list_content.xml index 79e4c329..5b2f8ecd 100644 --- a/ui/src/main/res/layout/acq_card_list_content.xml +++ b/ui/src/main/res/layout/acq_card_list_content.xml @@ -29,17 +29,7 @@ android:layout_height="wrap_content" android:nestedScrollingEnabled="false" android:orientation="vertical" - app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"> - - - - + app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/> \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_card_list_empty_stub.xml b/ui/src/main/res/layout/acq_card_list_empty_stub.xml deleted file mode 100644 index 3286901f..00000000 --- a/ui/src/main/res/layout/acq_card_list_empty_stub.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_card_list_stub.xml b/ui/src/main/res/layout/acq_card_list_stub.xml new file mode 100644 index 00000000..a4546454 --- /dev/null +++ b/ui/src/main/res/layout/acq_card_list_stub.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/values-ru/strings.xml b/ui/src/main/res/values-ru/strings.xml index 2e08b018..d0b9354d 100644 --- a/ui/src/main/res/values-ru/strings.xml +++ b/ui/src/main/res/values-ru/strings.xml @@ -26,4 +26,12 @@ %1$s • %2$s Ваши карты + + Попробуйте снова через пару минут + Понятно + Не загрузилось + + Обновить + Здесь будут ваши карты + Добавить \ No newline at end of file diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 2e226250..87e04fa9 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -28,4 +28,15 @@ %1$s • %2$s Your cards + + + Try again in a couple of minutes + Clear + + Not loaded + + Refresh + + This is where your cards will be + Add diff --git a/ui/src/main/res/values/styles.xml b/ui/src/main/res/values/styles.xml index e6bfea36..a4d6307e 100644 --- a/ui/src/main/res/values/styles.xml +++ b/ui/src/main/res/values/styles.xml @@ -146,6 +146,11 @@ bold + + + + From 5b15f85800ec497921485d1c0636b2dce2134367 Mon Sep 17 00:00:00 2001 From: jqwout Date: Wed, 9 Nov 2022 13:30:18 +0300 Subject: [PATCH 008/126] =?UTF-8?q?MC-7130-stubs=20=D0=B7=D0=B0=D0=B3?= =?UTF-8?q?=D0=BB=D1=83=D1=88=D0=BA=D0=B8=20=D0=B4=D0=BB=D1=8F=20=D0=BE?= =?UTF-8?q?=D1=88=D0=B8=D0=B1=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sample/src/main/res/values-ru/strings.xml | 2 - .../acquiring/sdk/adapters/CardListAdapter.kt | 132 ------------------ .../cards/list/ui/CardsListActivity.kt | 2 +- .../res/drawable-night/acq_add_new_card.xml | 28 ++++ ...ui_avatar__1_.xml => acq_add_new_card.xml} | 0 ui/src/main/res/drawable/tui_avatar.xml | 14 -- .../res/layout/acq_activity_card_list.xml | 11 +- .../main/res/layout/acq_card_list_content.xml | 23 ++- ui/src/main/res/layout/acq_card_list_item.xml | 1 + ui/src/main/res/layout/acq_item_card_list.xml | 71 ---------- .../cards/list/CardsBaseViewModelTest.kt | 27 +++- 11 files changed, 77 insertions(+), 234 deletions(-) delete mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/adapters/CardListAdapter.kt create mode 100644 ui/src/main/res/drawable-night/acq_add_new_card.xml rename ui/src/main/res/drawable/{tui_avatar__1_.xml => acq_add_new_card.xml} (100%) delete mode 100644 ui/src/main/res/drawable/tui_avatar.xml delete mode 100644 ui/src/main/res/layout/acq_item_card_list.xml diff --git a/sample/src/main/res/values-ru/strings.xml b/sample/src/main/res/values-ru/strings.xml index a9aea359..655d21d8 100644 --- a/sample/src/main/res/values-ru/strings.xml +++ b/sample/src/main/res/values-ru/strings.xml @@ -82,6 +82,4 @@ Платежные уведомления Продление подписки Подписка успешно продлена! - - %1$s • %2$s \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/adapters/CardListAdapter.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/adapters/CardListAdapter.kt deleted file mode 100644 index eb1fe930..00000000 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/adapters/CardListAdapter.kt +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright © 2020 Tinkoff Bank - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package ru.tinkoff.acquiring.sdk.adapters - -import android.content.Context -import android.content.res.Configuration -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.BaseAdapter -import android.widget.ImageView -import android.widget.TextView -import ru.tinkoff.acquiring.sdk.R -import ru.tinkoff.acquiring.sdk.localization.AsdkLocalization -import ru.tinkoff.acquiring.sdk.models.Card -import ru.tinkoff.acquiring.sdk.ui.customview.Shadow -import ru.tinkoff.acquiring.sdk.utils.CardSystemIconsHolder - - -/** - * @author Mariya Chernyadieva - */ -internal class CardListAdapter(private val context: Context): BaseAdapter() { - - private var selectedCardId: String? = null - var moreClickListener: OnMoreIconClickListener? = null - var cardSelectListener: CardSelectListener? = null - - private var cards = mutableListOf() - private val iconsHolder: CardSystemIconsHolder = CardSystemIconsHolder(context) - private var isDarkMode = false - - init { - isDarkMode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == - Configuration.UI_MODE_NIGHT_YES - } - - override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View { - var view: View? = convertView - val cardSelected = cards[position].cardId == selectedCardId - - if (view == null) { - view = LayoutInflater.from(context).inflate(R.layout.acq_item_card_list, parent, false) - } - view!!.findViewById(R.id.acq_item_card_background).background = if (cardSelected) { - Shadow(context, isDarkMode, R.color.acq_colorSelectedCardBackground).apply { shadowRadius = 0f } - } else { - Shadow(context, isDarkMode, R.color.acq_colorCardBackground) - } - - val cardImage = view.findViewById(R.id.acq_item_card_logo) - val cardNumber = view.findViewById(R.id.acq_item_card_number) - val cardDate = view.findViewById(R.id.acq_item_card_date) - - cardImage.setImageBitmap(iconsHolder.getCardSystemLogo(cards[position].pan!!)) - cardNumber.text = makeTextNumber(cards[position].pan!!) - cardDate.text = makeCardDate(cards[position].expDate!!) - val iconMore = view.findViewById(R.id.acq_item_card_more) - - iconMore.setOnClickListener { - moreClickListener?.onMoreIconClick(cards[position]) - } - - cardSelectListener?.let { listener -> - view.setOnClickListener { - selectedCardId = cards[position].cardId - listener.onCardSelected(cards[position]) - notifyDataSetChanged() - } - } - - return view - } - - override fun getItem(position: Int): Any { - return cards[position] - } - - override fun getItemId(position: Int): Long { - return 0 - } - - override fun getCount(): Int { - return cards.size - } - - fun setCards(cards: List) { - this.cards = cards.toMutableList() - notifyDataSetChanged() - } - - fun setSelectedCard(cardId: String) { - this.selectedCardId = cardId - notifyDataSetChanged() - } - - fun getLastPanNumbers(number: String): String { - return number.substring(number.length - 4, number.length) - } - - private fun makeTextNumber(number: String): String { - return String.format(AsdkLocalization.resources.cardListCardFormat!!, getLastPanNumbers(number)) - } - - private fun makeCardDate(date: String): String { - return "${date.subSequence(0, 2)}/${date.subSequence(2, date.length)}" - } - - interface OnMoreIconClickListener { - - fun onMoreIconClick(card: Card) - } - - interface CardSelectListener { - - fun onCardSelected(card: Card) - } -} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt index e9ab8d07..e1587ac5 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt @@ -77,7 +77,7 @@ internal class CardsListActivity : TransparentActivity() { viewModel.stateFlow.collectLatest { when (it) { is CardsListState.Content -> { - viewFlipper.showById(R.id.acq_card_list_view) + viewFlipper.showById(R.id.acq_card_list_content) cardsListAdapter.setCards(it.cards) } is CardsListState.Loading -> { diff --git a/ui/src/main/res/drawable-night/acq_add_new_card.xml b/ui/src/main/res/drawable-night/acq_add_new_card.xml new file mode 100644 index 00000000..6f5ba09d --- /dev/null +++ b/ui/src/main/res/drawable-night/acq_add_new_card.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/tui_avatar__1_.xml b/ui/src/main/res/drawable/acq_add_new_card.xml similarity index 100% rename from ui/src/main/res/drawable/tui_avatar__1_.xml rename to ui/src/main/res/drawable/acq_add_new_card.xml diff --git a/ui/src/main/res/drawable/tui_avatar.xml b/ui/src/main/res/drawable/tui_avatar.xml deleted file mode 100644 index ab3a83c1..00000000 --- a/ui/src/main/res/drawable/tui_avatar.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_activity_card_list.xml b/ui/src/main/res/layout/acq_activity_card_list.xml index f745ecd5..f5df81cd 100644 --- a/ui/src/main/res/layout/acq_activity_card_list.xml +++ b/ui/src/main/res/layout/acq_activity_card_list.xml @@ -37,14 +37,9 @@ android:id="@+id/acq_card_list_shimmer" layout="@layout/acq_card_list_shimmer" /> - - - + + android:layout_height="match_parent" + android:orientation="vertical"> + app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" + tools:itemCount="44" + tools:listitem="@layout/acq_card_list_item" /> + + + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_card_list_item.xml b/ui/src/main/res/layout/acq_card_list_item.xml index ae1e5280..c870b062 100644 --- a/ui/src/main/res/layout/acq_card_list_item.xml +++ b/ui/src/main/res/layout/acq_card_list_item.xml @@ -27,6 +27,7 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsBaseViewModelTest.kt b/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsBaseViewModelTest.kt index 8eaa3ec0..f0209962 100644 --- a/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsBaseViewModelTest.kt +++ b/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsBaseViewModelTest.kt @@ -14,6 +14,7 @@ import ru.tinkoff.acquiring.sdk.redesign.cards.list.presentation.CardsListViewMo import ru.tinkoff.acquiring.sdk.redesign.cards.list.ui.CardsListState import ru.tinkoff.acquiring.sdk.requests.GetCardListRequest import ru.tinkoff.acquiring.sdk.responses.GetCardListResponse +import ru.tinkoff.acquiring.sdk.utils.ConnectionChecker import ru.tinkoff.acquiring.sdk.utils.RequestResult import java.lang.Exception @@ -83,14 +84,33 @@ class CardsListViewModelTest { ) } + @Test + fun `when connection lost`() { + runViewModelCardsLoadTest( + "key", + RequestResult.Success( + GetCardListResponse( + arrayOf(mock { + on { pan } doReturn "3413413413413414" + on { cardId } doReturn "1" + on { status } doReturn CardStatus.DELETED + }) + ) + ), + connectionChecker = mock { on { isOnline() } doReturn false } + ) + } private inline fun runViewModelCardsLoadTest( key: String?, requestResult: RequestResult, recurrentOnly: Boolean = false, + connectionChecker: ConnectionChecker = mock { + on { isOnline() } doReturn true + } ) { runBlocking { - val viewModel = createViewModelMock(requestResult) + val viewModel = createViewModelMock(requestResult, connectionChecker) viewModel.loadData(key, recurrentOnly) delay(100) viewModel.stateFlow.test { @@ -105,7 +125,8 @@ class CardsListViewModelTest { } private fun createViewModelMock( - result: RequestResult + result: RequestResult, + connectionChecker: ConnectionChecker ): CardsListViewModel { val request = mock { on { executeFlow() } doReturn MutableStateFlow(result) @@ -113,7 +134,7 @@ class CardsListViewModelTest { val sdk = mock { on { getCardList(any()) } doReturn request } - return CardsListViewModel(sdk) + return CardsListViewModel(sdk, connectionChecker) } } \ No newline at end of file From b14732aa72ebb176392d24bdfcbc44c3e13a8e6f Mon Sep 17 00:00:00 2001 From: jqwout Date: Wed, 9 Nov 2022 14:30:42 +0300 Subject: [PATCH 009/126] =?UTF-8?q?MC-7093-delete=20=D0=A0=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D0=B9=D0=BD.=20=D0=A4=D0=BB=D0=BE=D1=83=20?= =?UTF-8?q?=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=B0=D0=BC=D0=B8.=20=D0=A3=D0=B4?= =?UTF-8?q?=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sdk/requests/RemoveCardRequest.kt | 9 ++ gradle/versions.gradle | 1 + sample/src/main/res/values-ru/strings.xml | 2 - ui/build.gradle | 1 + .../cards/list/adapters/CardsListAdapter.kt | 58 ++++++- .../cards/list/models/CardItemUiModel.kt | 4 +- .../list/presentation/CardsListViewModel.kt | 82 ++++++++-- .../cards/list/ui/CardsListActivity.kt | 140 +++++++++++----- .../redesign/cards/list/ui/CardsListState.kt | 29 +++- .../acquiring/sdk/utils/AcqSnackBar.kt | 42 +++++ .../acquiring/sdk/utils/CoroutineManager.kt | 13 +- ui/src/main/res/drawable/acq_ic_delete.xml | 13 ++ ui/src/main/res/drawable/acq_snackbar_bg.xml | 22 +++ ui/src/main/res/layout/acq_card_list_item.xml | 33 ++-- .../main/res/layout/acq_snackbar_layout.xml | 24 +++ ui/src/main/res/layout/acq_snackbar_view.xml | 6 + ui/src/main/res/menu/acq_card_list_menu.xml | 16 ++ ui/src/main/res/values/colors.xml | 2 + ui/src/main/res/values/styles.xml | 8 + ui/src/test/java/TurbineExt.kt | 11 ++ .../cards/list/CardsBaseViewModelTest.kt | 8 +- .../cards/list/CardsDeleteViewModelTest.kt | 152 ++++++++++++++++++ 22 files changed, 592 insertions(+), 84 deletions(-) create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBar.kt create mode 100644 ui/src/main/res/drawable/acq_ic_delete.xml create mode 100644 ui/src/main/res/drawable/acq_snackbar_bg.xml create mode 100644 ui/src/main/res/layout/acq_snackbar_layout.xml create mode 100644 ui/src/main/res/layout/acq_snackbar_view.xml create mode 100644 ui/src/main/res/menu/acq_card_list_menu.xml create mode 100644 ui/src/test/java/TurbineExt.kt create mode 100644 ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsDeleteViewModelTest.kt diff --git a/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/RemoveCardRequest.kt b/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/RemoveCardRequest.kt index 652f7d95..334318b1 100644 --- a/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/RemoveCardRequest.kt +++ b/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/RemoveCardRequest.kt @@ -16,8 +16,10 @@ package ru.tinkoff.acquiring.sdk.requests +import kotlinx.coroutines.flow.Flow import ru.tinkoff.acquiring.sdk.network.AcquiringApi.REMOVE_CARD_METHOD import ru.tinkoff.acquiring.sdk.responses.RemoveCardResponse +import ru.tinkoff.acquiring.sdk.utils.RequestResult /** * Удаляет привязанную карту @@ -56,4 +58,11 @@ class RemoveCardRequest : AcquiringRequest(REMOVE_CARD_METHO override fun execute(onSuccess: (RemoveCardResponse) -> Unit, onFailure: (Exception) -> Unit) { super.performRequest(this, RemoveCardResponse::class.java, onSuccess, onFailure) } + + /** + * Реактивный вызов метода API + */ + fun executeFlow(): Flow> { + return super.performRequestFlow(this, RemoveCardResponse::class.java) + } } \ No newline at end of file diff --git a/gradle/versions.gradle b/gradle/versions.gradle index 3681a643..7c2b72c3 100644 --- a/gradle/versions.gradle +++ b/gradle/versions.gradle @@ -31,6 +31,7 @@ ext { rootBeerVersion = '0.1.0' lifecycleRuntimeVersion = '2.4.0' recyclerviewVersion = '1.2.1' + materialVersion = '1.5.0' mokitoKotlinVersion = '4.0.0' mokitoInlineVersion = '3.5.13' diff --git a/sample/src/main/res/values-ru/strings.xml b/sample/src/main/res/values-ru/strings.xml index a9aea359..655d21d8 100644 --- a/sample/src/main/res/values-ru/strings.xml +++ b/sample/src/main/res/values-ru/strings.xml @@ -82,6 +82,4 @@ Платежные уведомления Продление подписки Подписка успешно продлена! - - %1$s • %2$s \ No newline at end of file diff --git a/ui/build.gradle b/ui/build.gradle index d33398ca..7d5fe9d8 100644 --- a/ui/build.gradle +++ b/ui/build.gradle @@ -60,6 +60,7 @@ dependencies { implementation "jp.wasabeef:blurry:${blurryVersion}" implementation "com.scottyab:rootbeer-lib:${rootBeerVersion}" implementation "androidx.recyclerview:recyclerview:${recyclerviewVersion}" + implementation "com.google.android.material:material:${materialVersion}" testImplementation 'junit:junit:4.13' testImplementation "org.mockito.kotlin:mockito-kotlin:${mokitoKotlinVersion}" diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt index 30a10aac..d2f04f74 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt @@ -4,12 +4,19 @@ import android.annotation.SuppressLint import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.ImageView import android.widget.TextView +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import ru.tinkoff.acquiring.sdk.R import ru.tinkoff.acquiring.sdk.redesign.cards.list.models.CardItemUiModel -class CardsListAdapter : RecyclerView.Adapter() { +/** + * Created by Ivan Golovachev + */ +class CardsListAdapter( + private val onDeleteClick: (CardItemUiModel) -> Unit +) : RecyclerView.Adapter() { private val cards = mutableListOf() @@ -20,8 +27,16 @@ class CardsListAdapter : RecyclerView.Adapter() notifyDataSetChanged() } - fun onRemoveCard(id: String) { - //TODO после задачи на удаление карты + @SuppressLint("NotifyDataSetChanged") + fun updateMode(cards: List) { + this.cards.clear() + this.cards.addAll(cards) + notifyItemRangeChanged(0, cards.size, PAYLOAD_CHANGE_MODE) + } + + fun onRemoveCard(indexAt: Int) { + this.cards.removeAt(indexAt) + notifyItemRemoved(indexAt) } fun onAddCard(card: CardItemUiModel) { @@ -35,7 +50,19 @@ class CardsListAdapter : RecyclerView.Adapter() } override fun onBindViewHolder(holder: CardViewHolder, position: Int) { - holder.bind(cards[position]) + holder.bind(cards[position], onDeleteClick) + } + + override fun onBindViewHolder( + holder: CardViewHolder, + position: Int, + payloads: MutableList + ) { + if (payloads.contains(PAYLOAD_CHANGE_MODE)) { + holder.bindDeleteVisibility(cards.get(position).showDelete) + } else { + super.onBindViewHolder(holder, position, payloads) + } } override fun getItemCount(): Int { @@ -44,14 +71,31 @@ class CardsListAdapter : RecyclerView.Adapter() class CardViewHolder(view: View) : RecyclerView.ViewHolder(view) { - private val cardNameView = itemView.findViewById(R.id.cardNameMasked) + private val cardNameView = + itemView.findViewById(R.id.acq_card_list_item_masked_name) + private val cardDeleteIcon = + itemView.findViewById(R.id.acq_card_list_item_delete) - fun bind(card: CardItemUiModel) { + fun bind( + card: CardItemUiModel, + onDeleteClick: (CardItemUiModel) -> Unit + ) { cardNameView.text = itemView.context.getString( R.string.card_list_item_card_name_masked_template, card.bankName, card.tail ) + bindDeleteVisibility(card.showDelete) + cardDeleteIcon.setOnClickListener { onDeleteClick(card) } + } + + fun bindDeleteVisibility(showDelete: Boolean) { + cardDeleteIcon.isVisible = showDelete } } -} \ No newline at end of file + + companion object { + const val PAYLOAD_CHANGE_MODE = "PAYLOAD_CHANGE_MODE" + } +} + diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/models/CardItemUiModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/models/CardItemUiModel.kt index 3dbf0857..cf0d147c 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/models/CardItemUiModel.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/models/CardItemUiModel.kt @@ -2,8 +2,8 @@ package ru.tinkoff.acquiring.sdk.redesign.cards.list.models import ru.tinkoff.acquiring.sdk.models.Card -class CardItemUiModel( - val card: Card, +data class CardItemUiModel( + private val card: Card, // TODO after brandByBin algo impl val bankName: String = "***", diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt index ded1619d..1675d84f 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt @@ -1,16 +1,26 @@ package ru.tinkoff.acquiring.sdk.redesign.cards.list.presentation +import androidx.annotation.VisibleForTesting import androidx.lifecycle.ViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import ru.tinkoff.acquiring.sdk.AcquiringSdk import ru.tinkoff.acquiring.sdk.models.Card import ru.tinkoff.acquiring.sdk.models.enums.CardStatus import ru.tinkoff.acquiring.sdk.redesign.cards.list.models.CardItemUiModel +import ru.tinkoff.acquiring.sdk.redesign.cards.list.ui.CardListEvent +import ru.tinkoff.acquiring.sdk.redesign.cards.list.ui.CardListMode import ru.tinkoff.acquiring.sdk.redesign.cards.list.ui.CardsListState import ru.tinkoff.acquiring.sdk.responses.GetCardListResponse import ru.tinkoff.acquiring.sdk.utils.ConnectionChecker import ru.tinkoff.acquiring.sdk.utils.CoroutineManager +/** + * Created by Ivan Golovachev + */ class CardsListViewModel( private val sdk: AcquiringSdk, private val connectionChecker: ConnectionChecker @@ -18,10 +28,17 @@ class CardsListViewModel( private val manager = CoroutineManager() - private val cardsListFlow = MutableStateFlow?>(null) + private var deleteJob: Job? = null + @VisibleForTesting val stateFlow = MutableStateFlow(CardsListState.Loading) + val stateUiFlow = stateFlow.filter { it.isInternal.not() } + + val modeFlow = stateFlow.map { it.mode } + + val eventFlow = MutableStateFlow(null) + fun loadData(customerKey: String?, recurrentOnly: Boolean) { if (connectionChecker.isOnline().not()) { stateFlow.tryEmit(CardsListState.NoNetwork) @@ -30,7 +47,7 @@ class CardsListViewModel( stateFlow.tryEmit(CardsListState.Loading) manager.launchOnBackground { if (customerKey == null) { - handleWithoutCustomerKey() + stateFlow.tryEmit(CardsListState.Error) return@launchOnBackground } @@ -41,17 +58,54 @@ class CardsListViewModel( ) } } + } + + fun deleteCard(model: CardItemUiModel, customerKey: String?) { + if (deleteJob?.isActive == true) { + return + } + + deleteJob = manager.launchOnBackground { + if (connectionChecker.isOnline().not()) { + eventFlow.value = CardListEvent.ShowError + return@launchOnBackground + } + if (customerKey == null) { + eventFlow.value = CardListEvent.ShowError + return@launchOnBackground + } + sdk.removeCard { + this.cardId = model.id + this.customerKey = customerKey + } + .executeFlow().collect { + it.process( + onSuccess = { r -> + handleDeleteCard(checkNotNull(r.cardId?.toString())) + deleteJob?.cancel() + }, + onFailure = { + eventFlow.value = CardListEvent.ShowError + deleteJob?.cancel() + } + ) + } + } + } + fun changeMode(mode: CardListMode) { + val list = (stateFlow.value as? CardsListState.Content)?.cards ?: return + val cards = list.map { it.copy(showDelete = mode == CardListMode.DELETE) } + stateFlow.value = CardsListState.Content(mode, false, cards) } private fun handleGetCardListResponse(it: GetCardListResponse, recurrentOnly: Boolean) { try { val uiCards = filterCards(it.cards, recurrentOnly) - cardsListFlow.tryEmit(uiCards) - if (uiCards.isEmpty()) { - stateFlow.tryEmit(CardsListState.Empty) + stateFlow.value = if (uiCards.isEmpty()) { + CardsListState.Empty } else { - stateFlow.tryEmit(CardsListState.Content(uiCards)) + CardsListState.Content(CardListMode.ADD, false, uiCards) } } catch (e: Exception) { handleGetCardListError(e) @@ -71,12 +125,20 @@ class CardsListViewModel( } private fun handleGetCardListError(it: Exception) { - cardsListFlow.tryEmit(emptyList()) - stateFlow.tryEmit(CardsListState.Error) + stateFlow.value = CardsListState.Error } - private fun handleWithoutCustomerKey() { - stateFlow.tryEmit(CardsListState.Error) + private fun handleDeleteCard(deletedCardId: String) { + val list = checkNotNull((stateFlow.value as? CardsListState.Content)?.cards).toMutableList() + val indexAt = list.indexOfFirst { it.id == deletedCardId } + list.removeAt(indexAt) + + if (list.isEmpty()) { + stateFlow.value = CardsListState.Empty + } else { + stateFlow.update { CardsListState.Content(it.mode, true, list) } + eventFlow.value = CardListEvent.RemoveCard(indexAt) + } } override fun onCleared() { diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt index e1587ac5..06246a8e 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt @@ -1,15 +1,20 @@ package ru.tinkoff.acquiring.sdk.redesign.cards.list.ui import android.os.Bundle +import android.view.Menu +import android.view.MenuItem import android.view.ViewGroup import android.view.View import android.widget.ImageView import android.widget.TextView +import android.widget.Toast import android.widget.ViewFlipper import androidx.core.view.children +import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch import ru.tinkoff.acquiring.sdk.R import ru.tinkoff.acquiring.sdk.models.options.screen.SavedCardsOptions @@ -21,13 +26,15 @@ import ru.tinkoff.acquiring.sdk.utils.showById internal class CardsListActivity : TransparentActivity() { - internal lateinit var viewModel: CardsListViewModel + private lateinit var viewModel: CardsListViewModel private lateinit var savedCardsOptions: SavedCardsOptions private lateinit var cardsListAdapter: CardsListAdapter private lateinit var viewFlipper: ViewFlipper private lateinit var cardShimmer: ViewGroup + private var mode = CardListMode.STUB + private val stubImage: ImageView by lazy(LazyThreadSafetyMode.NONE) { findViewById(R.id.acq_stub_img) } @@ -40,6 +47,9 @@ internal class CardsListActivity : TransparentActivity() { private val stubButtonView: TextView by lazy(LazyThreadSafetyMode.NONE) { findViewById(R.id.acq_stub_retry_button) } + private val addNewCard: TextView by lazy(LazyThreadSafetyMode.NONE) { + findViewById(R.id.acq_add_new_card) + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -57,6 +67,27 @@ internal class CardsListActivity : TransparentActivity() { subscribeOnState() } + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.acq_card_list_menu, menu) + menu.findItem(R.id.acq_card_list_action_change)?.isVisible = mode === CardListMode.ADD + menu.findItem(R.id.acq_card_list_action_complete)?.isVisible = mode === CardListMode.DELETE + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.acq_card_list_action_change -> { + viewModel.changeMode(CardListMode.DELETE) + true + } + R.id.acq_card_list_action_complete -> { + viewModel.changeMode(CardListMode.ADD) + true + } + else -> super.onOptionsItemSelected(item) + } + } + private fun initToolbar() { setSupportActionBar(findViewById(R.id.acq_toolbar)) supportActionBar?.setDisplayHomeAsUpEnabled(true) @@ -68,48 +99,85 @@ internal class CardsListActivity : TransparentActivity() { val recyclerView = findViewById(R.id.acq_card_list_view) viewFlipper = findViewById(R.id.acq_view_flipper) cardShimmer = viewFlipper.findViewById(R.id.acq_card_list_shimmer) - cardsListAdapter = CardsListAdapter() + cardsListAdapter = CardsListAdapter(onDeleteClick = { + viewModel.deleteCard(it, savedCardsOptions.customer.customerKey!!) + }) recyclerView.adapter = cardsListAdapter } private fun subscribeOnState() { lifecycleScope.launch { - viewModel.stateFlow.collectLatest { - when (it) { - is CardsListState.Content -> { - viewFlipper.showById(R.id.acq_card_list_content) - cardsListAdapter.setCards(it.cards) + launch { + viewModel.stateUiFlow.collectLatest { + when (it) { + is CardsListState.Content -> { + viewFlipper.showById(R.id.acq_card_list_content) + if (it.mode == CardListMode.ADD) { + cardsListAdapter.setCards(it.cards) + } else { + cardsListAdapter.updateMode(it.cards) + } + } + is CardsListState.Loading -> { + viewFlipper.showById(R.id.acq_card_list_shimmer) + AcqShimmerAnimator.animateSequentially( + cardShimmer.children.toList() + ) + } + is CardsListState.Error -> { + showStub( + imageResId = R.drawable.acq_ic_cards_list_error_stub, + titleTextRes = R.string.acq_cards_list_error_title, + subTitleTextRes = R.string.acq_cards_list_error_subtitle, + buttonTextRes = R.string.acq_cards_list_error_button + ) + } + is CardsListState.Empty -> { + showStub( + imageResId = R.drawable.acq_ic_cards_list_empty, + titleTextRes = null, + subTitleTextRes = R.string.acq_cards_list_empty_subtitle, + buttonTextRes = R.string.acq_cards_list_empty_button + ) + } + is CardsListState.NoNetwork -> { + showStub( + imageResId = R.drawable.acq_ic_no_network, + titleTextRes = R.string.acq_cards_list_no_network_title, + subTitleTextRes = R.string.acq_cards_list_no_network_subtitle, + buttonTextRes = R.string.acq_cards_list_no_network_button + ) + } } - is CardsListState.Loading -> { - viewFlipper.showById(R.id.acq_card_list_shimmer) - AcqShimmerAnimator.animateSequentially( - cardShimmer.children.toList() - ) - } - is CardsListState.Error -> { - showStub( - imageResId = R.drawable.acq_ic_cards_list_error_stub, - titleTextRes = R.string.acq_cards_list_error_title, - subTitleTextRes = R.string.acq_cards_list_error_subtitle, - buttonTextRes = R.string.acq_cards_list_error_button - ) - } - is CardsListState.Empty -> { - showStub( - imageResId = R.drawable.acq_ic_cards_list_empty, - titleTextRes = null, - subTitleTextRes = R.string.acq_cards_list_empty_subtitle, - buttonTextRes = R.string.acq_cards_list_empty_button - ) - } - is CardsListState.NoNetwork -> { - showStub( - imageResId = R.drawable.acq_ic_no_network, - titleTextRes = R.string.acq_cards_list_no_network_title, - subTitleTextRes = R.string.acq_cards_list_no_network_subtitle, - buttonTextRes = R.string.acq_cards_list_no_network_button - ) + } + } + launch { + viewModel.modeFlow.collectLatest { + mode = it + invalidateOptionsMenu() + addNewCard.isVisible = mode === CardListMode.ADD + } + } + launch { + viewModel.eventFlow.filterNotNull().collect { + when (it) { + is CardListEvent.RemoveCard -> { + cardsListAdapter.onRemoveCard(it.indexAt) + // TODO после задачи обработки ошибок + Toast.makeText( + this@CardsListActivity, + "карта удалена", + Toast.LENGTH_LONG + ) + .show() + } + is CardListEvent.ShowError -> { + // TODO после задачи обработки ошибок + Toast.makeText(this@CardsListActivity, "ошибка!", Toast.LENGTH_LONG) + .show() + } } + } } } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt index 6a3299ac..04779ab2 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt @@ -2,10 +2,27 @@ package ru.tinkoff.acquiring.sdk.redesign.cards.list.ui import ru.tinkoff.acquiring.sdk.redesign.cards.list.models.CardItemUiModel -sealed class CardsListState { - object Loading : CardsListState() - class Content(val cards: List) : CardsListState() - object Empty : CardsListState() - object Error : CardsListState() - object NoNetwork : CardsListState() +/** + * Created by Ivan Golovachev + */ +sealed class CardsListState(val mode: CardListMode, val isInternal: Boolean = false) { + object Loading : CardsListState(CardListMode.STUB) + object Empty : CardsListState(CardListMode.STUB) + object Error : CardsListState(CardListMode.STUB) + object NoNetwork : CardsListState(CardListMode.STUB) + + class Content( + mode: CardListMode, + isInternal: Boolean, + val cards: List, + ) : CardsListState(mode, isInternal) +} + +sealed class CardListEvent { + class RemoveCard(val indexAt: Int) : CardListEvent() + object ShowError : CardListEvent() +} + +enum class CardListMode { + ADD, DELETE, STUB } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBar.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBar.kt new file mode 100644 index 00000000..ac7b06d7 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBar.kt @@ -0,0 +1,42 @@ +package ru.tinkoff.acquiring.sdk.utils + +import android.view.LayoutInflater +import android.view.View +import android.widget.TextView +import com.google.android.material.snackbar.Snackbar +import com.google.android.material.snackbar.Snackbar.SnackbarLayout +import ru.tinkoff.acquiring.sdk.R + + +/** + * Created by Ivan Golovachev + */ +// TODO до конца не ясно что он должен из себя предсатвлять + +class AcqSnackBar { + + fun show(view: View, textValue: String) { + val snackbar = Snackbar.make(view, "", Snackbar.LENGTH_LONG) + + // inflate the custom_snackbar_view created previously + + // inflate the custom_snackbar_view created previously + + val customSnackView: View = LayoutInflater.from(view.context).inflate(R.layout.acq_snackbar_layout, null) + val textView = customSnackView.findViewById(R.id.acq_snackbar_text) + textView.text = textValue + + // set the background of the default snackbar as transparent + + // now change the layout of the snackbar + + // now change the layout of the snackbar + val snackbarLayout = snackbar.view as SnackbarLayout + + // set padding of the all corners as 0 + + // set padding of the all corners as 0 + snackbarLayout.setPadding(0, 0, 0, 0) + snackbar.show() + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt index 6c9292d8..de64595a 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt @@ -16,15 +16,8 @@ package ru.tinkoff.acquiring.sdk.utils -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.* import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine @@ -97,8 +90,8 @@ internal class CoroutineManager(private val exceptionHandler: (Throwable) -> Uni } } - fun launchOnBackground(block: suspend CoroutineScope.() -> Unit) { - coroutineScope.launch(IO) { + fun launchOnBackground(block: suspend CoroutineScope.() -> Unit): Job { + return coroutineScope.launch(IO) { block.invoke(this) } } diff --git a/ui/src/main/res/drawable/acq_ic_delete.xml b/ui/src/main/res/drawable/acq_ic_delete.xml new file mode 100644 index 00000000..1d71a949 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_delete.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/ui/src/main/res/drawable/acq_snackbar_bg.xml b/ui/src/main/res/drawable/acq_snackbar_bg.xml new file mode 100644 index 00000000..33a9a949 --- /dev/null +++ b/ui/src/main/res/drawable/acq_snackbar_bg.xml @@ -0,0 +1,22 @@ + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_card_list_item.xml b/ui/src/main/res/layout/acq_card_list_item.xml index c870b062..7c622c74 100644 --- a/ui/src/main/res/layout/acq_card_list_item.xml +++ b/ui/src/main/res/layout/acq_card_list_item.xml @@ -14,26 +14,41 @@ ~ limitations under the License. --> + android:orientation="horizontal"> + android:layout_height="26dp" + android:layout_gravity="center_vertical" + android:layout_marginStart="16dp" /> - + tools:text="Тинькофф банк • 7913" /> + + + + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_snackbar_layout.xml b/ui/src/main/res/layout/acq_snackbar_layout.xml new file mode 100644 index 00000000..8412b56c --- /dev/null +++ b/ui/src/main/res/layout/acq_snackbar_layout.xml @@ -0,0 +1,24 @@ + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_snackbar_view.xml b/ui/src/main/res/layout/acq_snackbar_view.xml new file mode 100644 index 00000000..77d9ef65 --- /dev/null +++ b/ui/src/main/res/layout/acq_snackbar_view.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/ui/src/main/res/menu/acq_card_list_menu.xml b/ui/src/main/res/menu/acq_card_list_menu.xml new file mode 100644 index 00000000..8953885d --- /dev/null +++ b/ui/src/main/res/menu/acq_card_list_menu.xml @@ -0,0 +1,16 @@ + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/values/colors.xml b/ui/src/main/res/values/colors.xml index 4306f1ed..35eff144 100644 --- a/ui/src/main/res/values/colors.xml +++ b/ui/src/main/res/values/colors.xml @@ -53,4 +53,6 @@ #08001024 #0f001024 + + #333333 diff --git a/ui/src/main/res/values/styles.xml b/ui/src/main/res/values/styles.xml index ce3a8d89..73514094 100644 --- a/ui/src/main/res/values/styles.xml +++ b/ui/src/main/res/values/styles.xml @@ -21,6 +21,9 @@ @color/acq_colorMainDark @color/acq_colorAccent @color/acq_colorText + + @color/acq_colorAccent + @style/AcqMenuItem @style/AcquiringDialogStyle @style/AcquiringContentLayout @@ -242,4 +245,9 @@ @drawable/acq_sf_cursor + + diff --git a/ui/src/test/java/TurbineExt.kt b/ui/src/test/java/TurbineExt.kt new file mode 100644 index 00000000..a296b2ef --- /dev/null +++ b/ui/src/test/java/TurbineExt.kt @@ -0,0 +1,11 @@ + +import kotlinx.coroutines.delay + +/** + * Created by Ivan Golovachev + */ + +//почему то всегда нужно немного подождать перед взамодействием с турбиной..... +internal suspend fun turbineDelay() { + delay(10) +} \ No newline at end of file diff --git a/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsBaseViewModelTest.kt b/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsBaseViewModelTest.kt index f0209962..f2b38534 100644 --- a/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsBaseViewModelTest.kt +++ b/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsBaseViewModelTest.kt @@ -16,8 +16,12 @@ import ru.tinkoff.acquiring.sdk.requests.GetCardListRequest import ru.tinkoff.acquiring.sdk.responses.GetCardListResponse import ru.tinkoff.acquiring.sdk.utils.ConnectionChecker import ru.tinkoff.acquiring.sdk.utils.RequestResult +import turbineDelay import java.lang.Exception +/** + * Created by Ivan Golovachev + */ class CardsListViewModelTest { @Test @@ -112,8 +116,8 @@ class CardsListViewModelTest { runBlocking { val viewModel = createViewModelMock(requestResult, connectionChecker) viewModel.loadData(key, recurrentOnly) - delay(100) - viewModel.stateFlow.test { + turbineDelay() + viewModel.stateUiFlow.test { val awaitNextEvent = awaitItem() val excClass = T::class assertTrue( diff --git a/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsDeleteViewModelTest.kt b/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsDeleteViewModelTest.kt new file mode 100644 index 00000000..06428dc9 --- /dev/null +++ b/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsDeleteViewModelTest.kt @@ -0,0 +1,152 @@ +package ru.tinkoff.acquiring.sdk.redesign.cards.list + +import app.cash.turbine.test +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.runBlocking +import org.junit.Assert +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import ru.tinkoff.acquiring.sdk.AcquiringSdk +import ru.tinkoff.acquiring.sdk.redesign.cards.list.models.CardItemUiModel +import ru.tinkoff.acquiring.sdk.redesign.cards.list.presentation.CardsListViewModel +import ru.tinkoff.acquiring.sdk.redesign.cards.list.ui.CardListEvent +import ru.tinkoff.acquiring.sdk.redesign.cards.list.ui.CardListMode +import ru.tinkoff.acquiring.sdk.redesign.cards.list.ui.CardsListState +import ru.tinkoff.acquiring.sdk.requests.RemoveCardRequest +import ru.tinkoff.acquiring.sdk.responses.RemoveCardResponse +import ru.tinkoff.acquiring.sdk.utils.RequestResult +import turbineDelay +import java.lang.Exception + +/** + * Created by Ivan Golovachev + */ +class CardsDeleteViewModelTest { + + @Test + fun `when card delete complete`() = runBlocking { + + val deletedCardId = 1L + val deletedCard = createCard(deletedCardId.toString()) + val otherCard = createCard("2") + + val vm = createViewModelMock( + initState = CardsListState.Content( + CardListMode.ADD, false, listOf(deletedCard, otherCard), + ), + hasConnection = true, + response = RequestResult.Success(RemoveCardResponse(deletedCardId)) + ) + + vm.deleteCard(deletedCard, "") + delay(100) + turbineDelay() + vm.eventFlow.filterNotNull().test { + val event = awaitItem() + Assert.assertTrue( + "event instance is ${event.javaClass.simpleName}\n expected is ${CardListEvent.RemoveCard::class.simpleName}", + event is CardListEvent.RemoveCard + ) + } + } + + @Test + fun `when card delete throw error`() = runBlocking { + + val deletedCardId = 1L + val deletedCard = createCard(deletedCardId.toString()) + + val vm = createViewModelMock( + initState = CardsListState.Content( + CardListMode.ADD, false, listOf(deletedCard), + ), + hasConnection = true, + response = RequestResult.Failure(Exception()) + ) + + vm.deleteCard(deletedCard, "") + turbineDelay() + vm.eventFlow.filterNotNull().test { + val event = awaitItem() + Assert.assertTrue( + "event instance is ${event.javaClass.simpleName}\n expected is ${CardListEvent.ShowError::class.simpleName}", + event is CardListEvent.ShowError + ) + } + } + + @Test + fun `when single card delete show empty stub`() = runBlocking { + + val deletedCardId = 1L + val deletedCard = createCard(deletedCardId.toString()) + val vm = createViewModelMock( + initState = CardsListState.Content( + CardListMode.ADD, false, listOf(deletedCard), + ), + hasConnection = true, + response = RequestResult.Success(RemoveCardResponse(deletedCardId)) + ) + + vm.deleteCard(deletedCard, "") + turbineDelay() + vm.stateUiFlow.test { + val state = awaitItem() + Assert.assertTrue( + "state instance is ${state.javaClass.simpleName}\n expected is ${CardListEvent.ShowError::class.simpleName}", + state is CardsListState.Empty + ) + } + } + + @Test + fun `when single card delete show empty`() = runBlocking { + + val deletedCardId = 1L + val deletedCard = createCard(deletedCardId.toString()) + val vm = createViewModelMock( + initState = CardsListState.Content( + CardListMode.ADD, false, listOf(deletedCard), + ), + hasConnection = true, + response = RequestResult.Success(RemoveCardResponse(deletedCardId)) + ) + + vm.deleteCard(deletedCard, "") + turbineDelay() + vm.stateUiFlow.test { + val state = awaitItem() + Assert.assertTrue( + "state instance is ${state.javaClass.simpleName}\n expected is ${CardListEvent.ShowError::class.simpleName}", + state is CardsListState.Empty + ) + } + } + + + private fun createViewModelMock( + initState: CardsListState, + hasConnection: Boolean, + response: RequestResult, + ): CardsListViewModel { + return CardsListViewModel( + createSdkMock(response), + mock { on { isOnline() } doReturn hasConnection } + ) + .apply { + stateFlow.value = initState + } + } + + private fun createSdkMock(response: RequestResult): AcquiringSdk { + val request: RemoveCardRequest = + mock { on { executeFlow() } doReturn MutableStateFlow(response) } + return mock { on { removeCard(any()) } doReturn request } + } + + private fun createCard(idMock: String): CardItemUiModel = mock { on { id } doReturn idMock } +} \ No newline at end of file From dc19b62e4d929e8e690178125b270eed8bd5b4f7 Mon Sep 17 00:00:00 2001 From: jqwout Date: Fri, 11 Nov 2022 16:18:00 +0300 Subject: [PATCH 010/126] =?UTF-8?q?MC-7093=20-=20=D0=BD=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D0=B5=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=BD=D0=B0=20=D1=83?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=B0=D1=80?= =?UTF-8?q?=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../list/presentation/CardsListViewModel.kt | 7 +- .../acquiring/sdk/utils/CoroutineManager.kt | 18 +- .../cards/list/CardsBaseViewModelTest.kt | 2 +- .../cards/list/CardsDeleteViewModelTest.kt | 210 ++++++++++-------- 4 files changed, 127 insertions(+), 110 deletions(-) diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt index 1675d84f..2afba202 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt @@ -21,13 +21,12 @@ import ru.tinkoff.acquiring.sdk.utils.CoroutineManager /** * Created by Ivan Golovachev */ -class CardsListViewModel( +internal class CardsListViewModel( private val sdk: AcquiringSdk, - private val connectionChecker: ConnectionChecker + private val connectionChecker: ConnectionChecker, + private val manager : CoroutineManager = CoroutineManager() ) : ViewModel() { - private val manager = CoroutineManager() - private var deleteJob: Job? = null @VisibleForTesting diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt index de64595a..0ecc0bda 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt @@ -18,6 +18,7 @@ package ru.tinkoff.acquiring.sdk.utils import kotlinx.coroutines.* import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Dispatchers.Main import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine @@ -25,13 +26,16 @@ import kotlin.coroutines.suspendCoroutine /** * @author Mariya Chernyadieva */ -internal class CoroutineManager(private val exceptionHandler: (Throwable) -> Unit) { +internal class CoroutineManager(private val exceptionHandler: (Throwable) -> Unit, + private val io: CoroutineDispatcher = IO, + private val main : CoroutineDispatcher = Main) { - constructor() : this({}) + constructor(io: CoroutineDispatcher = IO, + main : CoroutineDispatcher = Main) : this({}, io, main) private val job = SupervisorJob() private val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable -> launchOnMain { exceptionHandler(throwable) } } - private val coroutineScope = CoroutineScope(Dispatchers.Main + coroutineExceptionHandler + job) + private val coroutineScope = CoroutineScope(Main + coroutineExceptionHandler + job) private val disposableSet = hashSetOf() fun call(request: Request, onSuccess: (R) -> Unit, onFailure: ((Exception) -> Unit)? = null) { @@ -59,7 +63,7 @@ internal class CoroutineManager(private val exceptionHandler: (Throwable) -> Uni suspend fun callSuspended(request: Request): R { disposableSet.add(request) - return withContext(IO) { + return withContext(io) { suspendCoroutine { continuation -> request.execute({ result -> continuation.resume(result) @@ -78,20 +82,20 @@ internal class CoroutineManager(private val exceptionHandler: (Throwable) -> Uni } fun runWithDelay(timeMills: Long, block: () -> Unit) { - coroutineScope.launch(Dispatchers.Main) { + coroutineScope.launch(main) { delay(timeMills) block.invoke() } } fun launchOnMain(block: suspend CoroutineScope.() -> Unit) { - coroutineScope.launch(Dispatchers.Main) { + coroutineScope.launch(main) { block.invoke(this) } } fun launchOnBackground(block: suspend CoroutineScope.() -> Unit): Job { - return coroutineScope.launch(IO) { + return coroutineScope.launch(io) { block.invoke(this) } } diff --git a/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsBaseViewModelTest.kt b/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsBaseViewModelTest.kt index f2b38534..ad093f0a 100644 --- a/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsBaseViewModelTest.kt +++ b/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsBaseViewModelTest.kt @@ -22,7 +22,7 @@ import java.lang.Exception /** * Created by Ivan Golovachev */ -class CardsListViewModelTest { +internal class CardsListViewModelTest { @Test fun `when customer key are null`() { diff --git a/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsDeleteViewModelTest.kt b/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsDeleteViewModelTest.kt index 06428dc9..b2b655c0 100644 --- a/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsDeleteViewModelTest.kt +++ b/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsDeleteViewModelTest.kt @@ -1,15 +1,19 @@ package ru.tinkoff.acquiring.sdk.redesign.cards.list import app.cash.turbine.test -import kotlinx.coroutines.delay +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.last import kotlinx.coroutines.runBlocking import org.junit.Assert import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever import ru.tinkoff.acquiring.sdk.AcquiringSdk import ru.tinkoff.acquiring.sdk.redesign.cards.list.models.CardItemUiModel import ru.tinkoff.acquiring.sdk.redesign.cards.list.presentation.CardsListViewModel @@ -18,6 +22,8 @@ import ru.tinkoff.acquiring.sdk.redesign.cards.list.ui.CardListMode import ru.tinkoff.acquiring.sdk.redesign.cards.list.ui.CardsListState import ru.tinkoff.acquiring.sdk.requests.RemoveCardRequest import ru.tinkoff.acquiring.sdk.responses.RemoveCardResponse +import ru.tinkoff.acquiring.sdk.utils.ConnectionChecker +import ru.tinkoff.acquiring.sdk.utils.CoroutineManager import ru.tinkoff.acquiring.sdk.utils.RequestResult import turbineDelay import java.lang.Exception @@ -25,127 +31,135 @@ import java.lang.Exception /** * Created by Ivan Golovachev */ -class CardsDeleteViewModelTest { +internal class CardsDeleteViewModelTest { + + val defaultContent = CardsListState.Content( + CardListMode.ADD, false, listOf(createCard("1"), createCard("2")), + ) + + val extendsContent = CardsListState.Content( + CardListMode.ADD, false, listOf(createCard("1"), createCard("2"), createCard("3")), + ) @Test fun `when card delete complete`() = runBlocking { - - val deletedCardId = 1L - val deletedCard = createCard(deletedCardId.toString()) - val otherCard = createCard("2") - - val vm = createViewModelMock( - initState = CardsListState.Content( - CardListMode.ADD, false, listOf(deletedCard, otherCard), - ), - hasConnection = true, - response = RequestResult.Success(RemoveCardResponse(deletedCardId)) - ) - - vm.deleteCard(deletedCard, "") - delay(100) - turbineDelay() - vm.eventFlow.filterNotNull().test { - val event = awaitItem() - Assert.assertTrue( - "event instance is ${event.javaClass.simpleName}\n expected is ${CardListEvent.RemoveCard::class.simpleName}", - event is CardListEvent.RemoveCard - ) + with(Environment(initState = defaultContent)) { + setResponse(RequestResult.Success(RemoveCardResponse(1))) + vm.deleteCard(createCard("1"), "") + checkState() + checkEvent() } } @Test fun `when card delete throw error`() = runBlocking { + with(Environment(initState = defaultContent)) { + setResponse(RequestResult.Failure(Exception())) + vm.deleteCard(createCard("1"), "") + checkEvent() + checkState() + } + } + - val deletedCardId = 1L - val deletedCard = createCard(deletedCardId.toString()) - - val vm = createViewModelMock( - initState = CardsListState.Content( - CardListMode.ADD, false, listOf(deletedCard), - ), - hasConnection = true, - response = RequestResult.Failure(Exception()) - ) - - vm.deleteCard(deletedCard, "") - turbineDelay() - vm.eventFlow.filterNotNull().test { - val event = awaitItem() - Assert.assertTrue( - "event instance is ${event.javaClass.simpleName}\n expected is ${CardListEvent.ShowError::class.simpleName}", - event is CardListEvent.ShowError - ) + @Test + fun `when card delete is offline`() = runBlocking { + with(Environment(initState = defaultContent)) { + setResponse(RequestResult.Failure(Exception())) + setOnline(true) + vm.deleteCard(createCard("1"), "") + checkEvent() + checkState() } + } @Test - fun `when single card delete show empty stub`() = runBlocking { - - val deletedCardId = 1L - val deletedCard = createCard(deletedCardId.toString()) - val vm = createViewModelMock( - initState = CardsListState.Content( - CardListMode.ADD, false, listOf(deletedCard), - ), - hasConnection = true, - response = RequestResult.Success(RemoveCardResponse(deletedCardId)) - ) - - vm.deleteCard(deletedCard, "") - turbineDelay() - vm.stateUiFlow.test { - val state = awaitItem() - Assert.assertTrue( - "state instance is ${state.javaClass.simpleName}\n expected is ${CardListEvent.ShowError::class.simpleName}", - state is CardsListState.Empty - ) + fun `when card delete multiply show empty`() = runBlocking { + with(Environment(initState = defaultContent)) { + setResponse(RequestResult.Success(RemoveCardResponse(1))) + vm.deleteCard(createCard("1"), "") + checkEvent() + setResponse(RequestResult.Success(RemoveCardResponse(2))) + vm.deleteCard(createCard("2"), "") + checkEvent() + + checkState() } } @Test - fun `when single card delete show empty`() = runBlocking { - - val deletedCardId = 1L - val deletedCard = createCard(deletedCardId.toString()) - val vm = createViewModelMock( - initState = CardsListState.Content( - CardListMode.ADD, false, listOf(deletedCard), - ), - hasConnection = true, - response = RequestResult.Success(RemoveCardResponse(deletedCardId)) - ) - - vm.deleteCard(deletedCard, "") - turbineDelay() - vm.stateUiFlow.test { - val state = awaitItem() - Assert.assertTrue( - "state instance is ${state.javaClass.simpleName}\n expected is ${CardListEvent.ShowError::class.simpleName}", - state is CardsListState.Empty - ) + fun `when card delete multiply show last card`() = runBlocking { + with(Environment(initState = extendsContent)) { + setResponse(RequestResult.Success(RemoveCardResponse(1))) + vm.deleteCard(createCard("1"), "") + checkEvent() + setResponse(RequestResult.Success(RemoveCardResponse(2))) + vm.deleteCard(createCard("2"), "") + checkEvent() + + checkState() } } - private fun createViewModelMock( + class Environment( initState: CardsListState, - hasConnection: Boolean, - response: RequestResult, - ): CardsListViewModel { - return CardsListViewModel( - createSdkMock(response), - mock { on { isOnline() } doReturn hasConnection } - ) - .apply { - stateFlow.value = initState + val connectionMock: ConnectionChecker = mock { on { isOnline() } doReturn true }, + val asdk: AcquiringSdk = mock { } + ) { + val dispatcher: CoroutineDispatcher = Dispatchers.Default + + val vm = CardsListViewModel( + asdk, + connectionMock, + CoroutineManager(dispatcher, dispatcher) + ).apply { + stateFlow.value = initState + } + + fun setState(initState: CardsListState) { + vm.stateFlow.value = initState + } + + fun setOnline(isOnline: Boolean) { + whenever(connectionMock.isOnline()).doReturn(isOnline) + } + + fun setResponse(response: RequestResult) { + val request: RemoveCardRequest = + mock { on { executeFlow() } doReturn MutableStateFlow(response) } + + whenever(asdk.removeCard(any())).doReturn(request) + } + + + suspend inline fun checkState() { + vm.stateFlow.test { + val value = awaitItem() + + Assert.assertTrue( + "state instance is ${value.javaClass.simpleName}\n expected is ${T::class.simpleName}", + value is T + ) + cancelAndIgnoreRemainingEvents() } - } + } + - private fun createSdkMock(response: RequestResult): AcquiringSdk { - val request: RemoveCardRequest = - mock { on { executeFlow() } doReturn MutableStateFlow(response) } - return mock { on { removeCard(any()) } doReturn request } + suspend inline fun checkEvent() { + vm.eventFlow.filterNotNull().test { + awaitItem().let { + val event = it + + Assert.assertTrue( + "state instance is ${event?.javaClass?.simpleName}\n expected is ${T::class.simpleName}", + event is T + ) + cancelAndIgnoreRemainingEvents() + } + } + } } private fun createCard(idMock: String): CardItemUiModel = mock { on { id } doReturn idMock } From 6d7930876dd2b4f84313e1e5e24f6b9401d8a115 Mon Sep 17 00:00:00 2001 From: "i.khafizov" Date: Tue, 15 Nov 2022 20:27:09 +0400 Subject: [PATCH 011/126] MC-7129 bank recognition --- .../sdk/localization/AsdkLocalization.kt | 1 + .../carddatainput/CardDataInputFragment.kt | 14 +- .../sdk/ui/activities/AttachCardActivity.kt | 3 +- .../ui/activities/BaseAcquiringActivity.kt | 30 ++ .../sdk/ui/fragments/AttachCardFragment.kt | 13 +- .../tinkoff/acquiring/sdk/utils/BankIssuer.kt | 285 ++++++++++++++++++ .../acquiring/sdk/utils/ViewExtUtil.kt | 14 +- .../sdk/viewmodel/AttachCardViewModel.kt | 4 +- .../res/layout/acq_fragment_attach_card.xml | 6 + ui/src/main/res/values-ru/strings.xml | 10 + ui/src/main/res/values/strings.xml | 11 + 11 files changed, 377 insertions(+), 14 deletions(-) create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/BankIssuer.kt diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/localization/AsdkLocalization.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/localization/AsdkLocalization.kt index a7846f45..ad6591cd 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/localization/AsdkLocalization.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/localization/AsdkLocalization.kt @@ -26,6 +26,7 @@ import java.util.* /** * @author Mariya Chernyadieva */ +@Deprecated("Redesign") internal object AsdkLocalization { lateinit var resources: LocalizationResources diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt index 99064a38..f3b7d0b2 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt @@ -14,12 +14,14 @@ import kotlinx.android.synthetic.main.acq_fragment_card_data_input.* import ru.tinkoff.acquiring.sdk.R import ru.tinkoff.acquiring.sdk.cardscanners.CameraCardScanner import ru.tinkoff.acquiring.sdk.cardscanners.CardScanner +import ru.tinkoff.acquiring.sdk.smartfield.AcqTextFieldView import ru.tinkoff.acquiring.sdk.smartfield.BaubleClearButton import ru.tinkoff.acquiring.sdk.ui.customview.editcard.CardPaymentSystem import ru.tinkoff.acquiring.sdk.ui.customview.editcard.CardPaymentSystem.MASTER_CARD import ru.tinkoff.acquiring.sdk.ui.customview.editcard.CardPaymentSystem.VISA import ru.tinkoff.acquiring.sdk.ui.customview.editcard.validators.CardValidator import ru.tinkoff.acquiring.sdk.utils.SimpleTextWatcher.Companion.afterTextChanged +import ru.tinkoff.acquiring.sdk.utils.lazyView import ru.tinkoff.decoro.MaskImpl import ru.tinkoff.decoro.parser.UnderscoreDigitSlotsParser import ru.tinkoff.decoro.watchers.MaskFormatWatcher @@ -31,13 +33,13 @@ internal class CardDataInputFragment : Fragment() { var onComplete: ((CardDataInputFragment) -> Unit)? = null var validateNotExpired = false - val cardNumberInput get() = card_number_input - val expiryDateInput get() = expiry_date_input - val cvcInput get() = cvc_input + val cardNumberInput: AcqTextFieldView by lazyView(R.id.card_number_input) + val expiryDateInput: AcqTextFieldView by lazyView(R.id.expiry_date_input) + val cvcInput: AcqTextFieldView by lazyView(R.id.cvc_input) - val cardNumber get() = CardNumberFormatter.normalizeCardNumber(card_number_input.text) - val expiryDate get() = expiry_date_input.text.orEmpty() - val cvc get() = cvc_input.text.orEmpty() + val cardNumber get() = CardNumberFormatter.normalizeCardNumber(cardNumberInput.text) + val expiryDate get() = expiryDateInput.text.orEmpty() + val cvc get() = cvcInput.text.orEmpty() override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = inflater.inflate(R.layout.acq_fragment_card_data_input, container, false) diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/AttachCardActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/AttachCardActivity.kt index 5966c280..7ca6f5fa 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/AttachCardActivity.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/AttachCardActivity.kt @@ -76,8 +76,7 @@ internal class AttachCardActivity : TransparentActivity() { is FinishWithErrorScreenState -> finishWithError(screenState.error) is ErrorScreenState -> { if (supportFragmentManager.findFragmentById(R.id.acq_activity_fl_container) !is LoopConfirmationFragment) { - showErrorScreen(screenState.message) { - hideErrorScreen() + showErrorDialog(message = screenState.message) { attachCardViewModel.createEvent(ErrorButtonClickedEvent) } } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/BaseAcquiringActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/BaseAcquiringActivity.kt index fa5c0c23..3c6dc24e 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/BaseAcquiringActivity.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/BaseAcquiringActivity.kt @@ -26,6 +26,8 @@ import android.view.View import android.widget.Button import android.widget.ProgressBar import android.widget.TextView +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate import androidx.fragment.app.Fragment @@ -129,6 +131,34 @@ internal open class BaseAcquiringActivity : AppCompatActivity() { } } + protected fun showErrorDialog( + @StringRes title: Int = R.string.acq_error, + @StringRes message: Int, + @StringRes buttonText: Int = R.string.acq_ok, + onButtonClick: (() -> Unit)? = null + ) { + AlertDialog.Builder(this) + .setTitle(title) + .setMessage(message) + .setPositiveButton(buttonText) { _, _ -> + onButtonClick?.invoke() + }.show() + } + + protected fun showErrorDialog( + title: String = getString(R.string.acq_error), + message: String, + buttonText: String = getString(R.string.acq_ok), + onButtonClick: (() -> Unit)? = null + ) { + AlertDialog.Builder(this) + .setTitle(title) + .setMessage(message) + .setPositiveButton(buttonText) { _, _ -> + onButtonClick?.invoke() + }.show() + } + protected fun hideErrorScreen() { val errorView = findViewById(R.id.acq_error_ll_container) content = findViewById(R.id.acq_content) diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/fragments/AttachCardFragment.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/fragments/AttachCardFragment.kt index dbc445f5..75da993a 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/fragments/AttachCardFragment.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/fragments/AttachCardFragment.kt @@ -20,8 +20,9 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.core.view.isVisible import androidx.lifecycle.ViewModelProvider -import kotlinx.android.synthetic.main.acq_fragment_attach_card.* import ru.tinkoff.acquiring.sdk.R import ru.tinkoff.acquiring.sdk.redesign.common.carddatainput.CardDataInputFragment import ru.tinkoff.acquiring.sdk.models.ErrorButtonClickedEvent @@ -32,6 +33,8 @@ import ru.tinkoff.acquiring.sdk.models.ScreenState import ru.tinkoff.acquiring.sdk.models.options.screen.AttachCardOptions import ru.tinkoff.acquiring.sdk.models.paysources.CardData import ru.tinkoff.acquiring.sdk.ui.activities.BaseAcquiringActivity +import ru.tinkoff.acquiring.sdk.ui.customview.LoaderButton +import ru.tinkoff.acquiring.sdk.utils.lazyView import ru.tinkoff.acquiring.sdk.viewmodel.AttachCardViewModel /** @@ -46,6 +49,9 @@ internal class AttachCardFragment : BaseAcquiringFragment() { get() = childFragmentManager .findFragmentById(R.id.fragment_card_data_input) as CardDataInputFragment + private val attachButton: LoaderButton by lazyView(R.id.acq_attach_btn_attach) + private val touchInterceptor: FrameLayout by lazyView(R.id.acq_touch_interceptor) + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = inflater.inflate(R.layout.acq_fragment_attach_card, container, false) @@ -59,7 +65,7 @@ internal class AttachCardFragment : BaseAcquiringFragment() { // todo secure keyboard? } - acq_attach_btn_attach.setOnClickListener { processAttach() } + attachButton.setOnClickListener { processAttach() } attachCardViewModel = ViewModelProvider(requireActivity()).get(AttachCardViewModel::class.java) val isErrorShowing = attachCardViewModel.screenStateLiveData.value is ErrorScreenState @@ -78,7 +84,8 @@ internal class AttachCardFragment : BaseAcquiringFragment() { } private fun handleLoadState(loadState: LoadState) { - acq_attach_btn_attach.isLoading = loadState == LoadingState + attachButton.isLoading = loadState == LoadingState + touchInterceptor.isVisible = loadState == LoadingState } private fun handleScreenState(screenState: ScreenState) { diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/BankIssuer.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/BankIssuer.kt new file mode 100644 index 00000000..a97b2303 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/BankIssuer.kt @@ -0,0 +1,285 @@ +package ru.tinkoff.acquiring.sdk.utils + +import android.content.Context +import ru.tinkoff.acquiring.sdk.R + +enum class BankIssuer(val bins: Collection) { + + SBERBANK(SBERBANK_BINS), + VTB(VTB_BINS), + ALFABANK(ALFABANK_BINS), + TINKOFF(TINKOFF_BINS), + RAIFFEISEN(RAIFFEISEN_BINS), + GAZPROMBANK(GAZPROMBANK_BINS), + UNKNOWN(setOf()), + OTHER(setOf()); + + fun matches(cardNumber: String): Boolean = bins.any { cardNumber.startsWith(it) } + + fun getCaption(context: Context): String? = when (this) { + SBERBANK -> context.getString(R.string.acq_bank_issuer_sberbank) + VTB -> context.getString(R.string.acq_bank_issuer_vtb) + ALFABANK -> context.getString(R.string.acq_bank_issuer_alfabank) + TINKOFF -> context.getString(R.string.acq_bank_issuer_tinkoff) + RAIFFEISEN -> context.getString(R.string.acq_bank_issuer_raiffeizen) + GAZPROMBANK -> context.getString(R.string.acq_bank_issuer_gazprombank) + else -> null + } + + companion object { + + fun resolve(cardNumber: String): BankIssuer { + if (cardNumber.length < 6) return UNKNOWN + return values().find { bank -> bank.matches(cardNumber) } ?: OTHER + } + } +} + +private val SBERBANK_BINS = setOf( + "427402", + "427406", + "427411", + "427416", + "427417", + "427418", + "427420", + "427422", + "427425", + "427427", + "427428", + "427430", + "427432", + "427433", + "427436", + "427438", + "427444", + "427448", + "427449", + "427459", + "427466", + "427472", + "427475", + "427477", + "427499", + "427600", + "427601", + "427602", + "427616", + "427620", + "427622", + "427625", + "427635", + "427648", + "427659", + "427666", + "427672", + "427674", + "427677", + "427680", + "427699", + "427901", + "427902", + "427916", + "427920", + "427922", + "427925", + "427930", + "427948", + "427959", + "427966", + "427972", + "427975", + "427977", + "427999", + "527576", + "531310", + "546901", + "546916", + "546920", + "546922", + "546925", + "546935", + "546959", + "546966", + "546972", + "546974", + "546998", + "547901", + "547905", + "547910", + "547920", + "547922", + "547925", + "547927", + "547928", + "547930", + "547932", + "547935", + "547938", + "547940", + "547942", + "547947", + "547948", + "547949", + "547959", + "547966", + "547969", + "547972", + "547976", + "547998", + "548401", + "548410", + "548416", + "548420", + "548422", + "548425", + "548430", + "548435", + "548438", + "548440", + "548442", + "548447", + "548454", + "548459", + "548466", + "548468", + "548472", + "548476", + "548498", + "639002", + "676195", + "676196", + "676280", +) + +private val VTB_BINS = setOf( + "418868", + "418869", + "418870", + "421191", + "426375", + "490809", + "515775", + "524895", + "525773", + "525787", + "542104", + "552216", + "554363", + "558481", +) + +private val ALFABANK_BINS = setOf( + "415400", + "415428", + "415429", + "415481", + "415482", + "419539", + "419540", + "427714", + "428804", + "428905", + "428906", + "431417", + "431727", + "434135", + "439000", + "458279", + "458410", + "458411", + "477960", + "477964", + "479004", + "479087", +) + +private val TINKOFF_BINS = setOf( + "220070", + "437772", + "437773", + "437783", + "470127", + "518901", + "521324", + "524468", + "528041", + "538994", + "551960", + "553420", + "553691", +) + +private val RAIFFEISEN_BINS = setOf( + "402178", + "402179", + "404807", + "404885", + "420705", + "422287", + "425884", + "447603", + "447624", + "462729", + "462730", + "462758", + "510069", + "510070", + "515876", + "528053", + "528808", + "528809", + "530867", + "533594", + "533616", + "536392", + "542772", + "544237", + "545115", + "558273", + "676625", +) + +private val GAZPROMBANK_BINS = setOf( + "404136", + "404270", + "424974", + "424975", + "424976", + "426890", + "427326", + "487415", + "487416", + "487417", + "489354", + "518816", + "518902", + "521155", + "522193", + "522477", + "522988", + "525740", + "526483", + "529278", + "530993", + "532684", + "534130", + "539839", + "540664", + "542255", + "543672", + "543762", + "544026", + "544561", + "545101", + "547348", + "548027", + "548999", + "549000", + "549098", + "549600", + "552702", + "556052", + "558355", + "676454", +) \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/ViewExtUtil.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/ViewExtUtil.kt index 551a70e2..623e0014 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/ViewExtUtil.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/ViewExtUtil.kt @@ -1,5 +1,6 @@ package ru.tinkoff.acquiring.sdk.utils +import android.app.Activity import android.content.Context import android.graphics.Matrix import android.graphics.Rect @@ -8,6 +9,8 @@ import android.util.TypedValue import android.view.View import android.view.ViewGroup import android.view.ViewParent +import androidx.annotation.IdRes +import androidx.fragment.app.Fragment import kotlin.math.roundToInt import kotlin.math.roundToLong @@ -120,4 +123,13 @@ internal fun lerp(start: Long, end: Long, fraction: Float): Long { internal fun lerp(start: Float, end: Float, fraction: Float): Float { return (start + (end - start) * fraction) -} \ No newline at end of file +} + +internal fun lazyUnsafe(initializer: () -> T): Lazy = + lazy(LazyThreadSafetyMode.NONE, initializer) + +internal fun Fragment.lazyView(@IdRes id: Int): Lazy = + lazyUnsafe { requireView().findViewById(id) } + +internal fun Activity.lazyView(@IdRes id: Int): Lazy = + lazyUnsafe { findViewById(id) } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/AttachCardViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/AttachCardViewModel.kt index 2a4af33a..6c509f9d 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/AttachCardViewModel.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/AttachCardViewModel.kt @@ -20,6 +20,7 @@ import android.app.Application import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import ru.tinkoff.acquiring.sdk.AcquiringSdk +import ru.tinkoff.acquiring.sdk.R import ru.tinkoff.acquiring.sdk.exceptions.AcquiringApiException import ru.tinkoff.acquiring.sdk.exceptions.AcquiringSdkException import ru.tinkoff.acquiring.sdk.localization.AsdkLocalization @@ -105,8 +106,7 @@ internal class AttachCardViewModel( if (needHandleErrorsInSdk && it is AcquiringApiException) { if (it.response != null && AcquiringApi.errorCodesAttachedCard.contains(it.response!!.errorCode)) { changeScreenState(LoadedState) - changeScreenState(ErrorScreenState(AsdkLocalization.resources.addCardErrorErrorAttached - ?: AsdkLocalization.resources.payDialogErrorFallbackMessage!!)) + changeScreenState(ErrorScreenState(context.getString(R.string.acq_attach_card_error))) } else handleException(it) } else handleException(it) } diff --git a/ui/src/main/res/layout/acq_fragment_attach_card.xml b/ui/src/main/res/layout/acq_fragment_attach_card.xml index 583e93e3..2fa14af7 100644 --- a/ui/src/main/res/layout/acq_fragment_attach_card.xml +++ b/ui/src/main/res/layout/acq_fragment_attach_card.xml @@ -36,4 +36,10 @@ android:layout_marginBottom="24dp" app:acq_text="@string/acq_attach_card_btn" /> + + \ No newline at end of file diff --git a/ui/src/main/res/values-ru/strings.xml b/ui/src/main/res/values-ru/strings.xml index 6cefd9cb..dc0992bc 100644 --- a/ui/src/main/res/values-ru/strings.xml +++ b/ui/src/main/res/values-ru/strings.xml @@ -42,6 +42,9 @@ Код Добавить + Ошибка + Ошибка добавления карты + Понятно Доступен %d символ @@ -56,4 +59,11 @@ Осталось %d символов + Тинькофф + Альфа банк + Газпромбанк + ВТБ + Сбербанк + Райффайзен + \ No newline at end of file diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index a0bf0b8b..10f2ca13 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -45,6 +45,10 @@ This is where your cards will be Add + Error + Error attaching card + OK + %d symbol available %d symbols available @@ -54,4 +58,11 @@ %d symbols remaining + Tinkoff + Alfabank + VTB + Sberbank + Raiffeizen + Gazprombank + From 63d18414a9523f90f90681a86eb0ce895771f8f0 Mon Sep 17 00:00:00 2001 From: jqwout Date: Fri, 11 Nov 2022 18:52:25 +0300 Subject: [PATCH 012/126] =?UTF-8?q?MC-7093=20-=20=D0=BD=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D0=B5=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=BD=D0=B0=20=D1=83?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=B0=D1=80?= =?UTF-8?q?=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/build.gradle | 1 + .../cards/list/adapters/CardsListAdapter.kt | 18 ++- .../cards/list/models/CardItemUiModel.kt | 4 +- .../list/presentation/CardsListViewModel.kt | 29 +++-- .../cards/list/ui/CardsListActivity.kt | 62 ++++----- .../redesign/cards/list/ui/CardsListState.kt | 7 +- .../acquiring/sdk/utils/AcqSnackBar.kt | 42 ------- .../acquiring/sdk/utils/AcqSnackBarHelper.kt | 54 ++++++++ .../res/drawable/acq_ic_cardlist_delete.xml | 13 ++ ui/src/main/res/drawable/acq_snackbar_bg.xml | 2 +- .../res/layout/acq_activity_card_list.xml | 2 +- ui/src/main/res/layout/acq_card_list_item.xml | 2 +- .../main/res/layout/acq_snackbar_layout.xml | 28 +++-- ui/src/main/res/values-ru/strings.xml | 3 + ui/src/main/res/values/colors.xml | 1 + ui/src/main/res/values/strings.xml | 4 + ui/src/main/res/values/styles.xml | 5 +- ui/src/test/java/TurbineExt.kt | 14 ++- ui/src/test/java/common/AssertExt.kt | 14 +++ ui/src/test/java/common/CollectDataExt.kt | 53 ++++++++ .../cards/list/CardsDeleteViewModelTest.kt | 118 ++++++++++-------- 21 files changed, 310 insertions(+), 166 deletions(-) delete mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBar.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBarHelper.kt create mode 100644 ui/src/main/res/drawable/acq_ic_cardlist_delete.xml create mode 100644 ui/src/test/java/common/AssertExt.kt create mode 100644 ui/src/test/java/common/CollectDataExt.kt diff --git a/ui/build.gradle b/ui/build.gradle index 5b8d7394..34a5e6bb 100644 --- a/ui/build.gradle +++ b/ui/build.gradle @@ -67,6 +67,7 @@ dependencies { testImplementation "org.mockito.kotlin:mockito-kotlin:${mokitoKotlinVersion}" testImplementation "org.mockito:mockito-inline:${mokitoInlineVersion}" testImplementation "app.cash.turbine:turbine:${turbineVersion}" + testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4' androidTestImplementation 'com.android.support.test:runner:1.0.2' androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2' } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt index d2f04f74..24ecbfcd 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt @@ -22,16 +22,14 @@ class CardsListAdapter( @SuppressLint("NotifyDataSetChanged") fun setCards(cards: List) { - this.cards.clear() - this.cards.addAll(cards) - notifyDataSetChanged() - } - - @SuppressLint("NotifyDataSetChanged") - fun updateMode(cards: List) { - this.cards.clear() - this.cards.addAll(cards) - notifyItemRangeChanged(0, cards.size, PAYLOAD_CHANGE_MODE) + if (this.cards.isEmpty()) { + this.cards.addAll(cards) + notifyDataSetChanged() + } else { + this.cards.clear() + this.cards.addAll(cards) + notifyItemRangeChanged(0, cards.size, PAYLOAD_CHANGE_MODE) + } } fun onRemoveCard(indexAt: Int) { diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/models/CardItemUiModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/models/CardItemUiModel.kt index cf0d147c..a7441192 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/models/CardItemUiModel.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/models/CardItemUiModel.kt @@ -9,7 +9,9 @@ data class CardItemUiModel( val bankName: String = "***", // TODO after delete card task - val showDelete: Boolean = false + val showDelete: Boolean = false, + + val isBlocked: Boolean = false ) { val id = card.cardId diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt index 2afba202..b59e25bc 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt @@ -3,10 +3,8 @@ package ru.tinkoff.acquiring.sdk.redesign.cards.list.presentation import androidx.annotation.VisibleForTesting import androidx.lifecycle.ViewModel import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* import ru.tinkoff.acquiring.sdk.AcquiringSdk import ru.tinkoff.acquiring.sdk.models.Card import ru.tinkoff.acquiring.sdk.models.enums.CardStatus @@ -24,13 +22,13 @@ import ru.tinkoff.acquiring.sdk.utils.CoroutineManager internal class CardsListViewModel( private val sdk: AcquiringSdk, private val connectionChecker: ConnectionChecker, - private val manager : CoroutineManager = CoroutineManager() + private val manager: CoroutineManager = CoroutineManager() ) : ViewModel() { private var deleteJob: Job? = null @VisibleForTesting - val stateFlow = MutableStateFlow(CardsListState.Loading) + val stateFlow = MutableStateFlow(CardsListState.Shimmer) val stateUiFlow = stateFlow.filter { it.isInternal.not() } @@ -43,7 +41,7 @@ internal class CardsListViewModel( stateFlow.tryEmit(CardsListState.NoNetwork) return } - stateFlow.tryEmit(CardsListState.Loading) + stateFlow.tryEmit(CardsListState.Shimmer) manager.launchOnBackground { if (customerKey == null) { stateFlow.tryEmit(CardsListState.Error) @@ -77,13 +75,17 @@ internal class CardsListViewModel( this.cardId = model.id this.customerKey = customerKey } - .executeFlow().collect { + .executeFlow() + .onStart { eventFlow.value = CardListEvent.RemoveCardProgress } + .collect { it.process( onSuccess = { r -> handleDeleteCard(checkNotNull(r.cardId?.toString())) deleteJob?.cancel() }, onFailure = { + val list = checkNotNull((stateFlow.value as? CardsListState.Content)?.cards) + stateFlow.update { CardsListState.Content(it.mode, true, list) } eventFlow.value = CardListEvent.ShowError deleteJob?.cancel() } @@ -93,9 +95,11 @@ internal class CardsListViewModel( } fun changeMode(mode: CardListMode) { - val list = (stateFlow.value as? CardsListState.Content)?.cards ?: return - val cards = list.map { it.copy(showDelete = mode == CardListMode.DELETE) } - stateFlow.value = CardsListState.Content(mode, false, cards) + stateFlow.update { state -> + val prev = state as CardsListState.Content + val cards = prev.cards.map { it.copy(showDelete = mode == CardListMode.DELETE, isBlocked = it.isBlocked) } + CardsListState.Content(mode, false, cards) + } } private fun handleGetCardListResponse(it: GetCardListResponse, recurrentOnly: Boolean) { @@ -134,9 +138,10 @@ internal class CardsListViewModel( if (list.isEmpty()) { stateFlow.value = CardsListState.Empty + eventFlow.value = CardListEvent.RemoveCardSuccess(null) } else { stateFlow.update { CardsListState.Content(it.mode, true, list) } - eventFlow.value = CardListEvent.RemoveCard(indexAt) + eventFlow.value = CardListEvent.RemoveCardSuccess(indexAt) } } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt index 8d093a81..12604096 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt @@ -7,7 +7,6 @@ import android.view.ViewGroup import android.view.View import android.widget.ImageView import android.widget.TextView -import android.widget.Toast import android.widget.ViewFlipper import androidx.core.view.children import androidx.core.view.isVisible @@ -23,6 +22,7 @@ import ru.tinkoff.acquiring.sdk.redesign.cards.list.adapters.CardsListAdapter import ru.tinkoff.acquiring.sdk.redesign.cards.list.presentation.CardsListViewModel import ru.tinkoff.acquiring.sdk.redesign.common.util.AcqShimmerAnimator import ru.tinkoff.acquiring.sdk.ui.activities.TransparentActivity +import ru.tinkoff.acquiring.sdk.utils.AcqSnackBarHelper import ru.tinkoff.acquiring.sdk.utils.showById internal class CardsListActivity : TransparentActivity() { @@ -30,9 +30,11 @@ internal class CardsListActivity : TransparentActivity() { private lateinit var viewModel: CardsListViewModel private lateinit var savedCardsOptions: SavedCardsOptions + private lateinit var recyclerView: RecyclerView private lateinit var cardsListAdapter: CardsListAdapter private lateinit var viewFlipper: ViewFlipper private lateinit var cardShimmer: ViewGroup + private lateinit var snackBarHelper: AcqSnackBarHelper private var mode = CardListMode.STUB @@ -101,13 +103,14 @@ internal class CardsListActivity : TransparentActivity() { } private fun initViews() { - val recyclerView = findViewById(R.id.acq_card_list_view) + recyclerView = findViewById(R.id.acq_card_list_view) viewFlipper = findViewById(R.id.acq_view_flipper) cardShimmer = viewFlipper.findViewById(R.id.acq_card_list_shimmer) cardsListAdapter = CardsListAdapter(onDeleteClick = { viewModel.deleteCard(it, savedCardsOptions.customer.customerKey!!) }) recyclerView.adapter = cardsListAdapter + snackBarHelper = AcqSnackBarHelper(findViewById(R.id.acq_card_list_root)) } private fun subscribeOnState() { @@ -134,13 +137,9 @@ internal class CardsListActivity : TransparentActivity() { when (it) { is CardsListState.Content -> { viewFlipper.showById(R.id.acq_card_list_content) - if (it.mode == CardListMode.ADD) { - cardsListAdapter.setCards(it.cards) - } else { - cardsListAdapter.updateMode(it.cards) - } + cardsListAdapter.setCards(it.cards) } - is CardsListState.Loading -> { + is CardsListState.Shimmer -> { viewFlipper.showById(R.id.acq_card_list_shimmer) AcqShimmerAnimator.animateSequentially( cardShimmer.children.toList() @@ -153,6 +152,9 @@ internal class CardsListActivity : TransparentActivity() { subTitleTextRes = R.string.acq_cards_list_error_subtitle, buttonTextRes = R.string.acq_cards_list_error_button ) + stubButtonView.setOnClickListener { + finish() + } } is CardsListState.Empty -> { showStub( @@ -161,6 +163,9 @@ internal class CardsListActivity : TransparentActivity() { subTitleTextRes = R.string.acq_cards_list_empty_subtitle, buttonTextRes = R.string.acq_cards_list_empty_button ) + stubButtonView.setOnClickListener { + //todo навигация с результатом о привязке карты + } } is CardsListState.NoNetwork -> { showStub( @@ -169,6 +174,12 @@ internal class CardsListActivity : TransparentActivity() { subTitleTextRes = R.string.acq_cards_list_no_network_subtitle, buttonTextRes = R.string.acq_cards_list_no_network_button ) + stubButtonView.setOnClickListener { + viewModel.loadData( + savedCardsOptions.customer.customerKey, + options.features.showOnlyRecurrentCards + ) + } } } } @@ -178,28 +189,26 @@ internal class CardsListActivity : TransparentActivity() { private fun CoroutineScope.subscribeOnEvents() { launch { viewModel.eventFlow.filterNotNull().collect { + recyclerView.alpha = if (it is CardListEvent.RemoveCardProgress) 0.5f else 1f + recyclerView.isEnabled = it !is CardListEvent.RemoveCardProgress + when (it) { - is CardListEvent.RemoveCard -> { - cardsListAdapter.onRemoveCard(it.indexAt) - // TODO после задачи обработки ошибок - Toast.makeText( - this@CardsListActivity, - "карта удалена", - Toast.LENGTH_LONG + is CardListEvent.RemoveCardProgress -> { + snackBarHelper.show( + R.string.acq_cardlist_snackbar_remove_progress, true ) - .show() + } + is CardListEvent.RemoveCardSuccess -> { + it.indexAt?.let(cardsListAdapter::onRemoveCard) + snackBarHelper.hide() } is CardListEvent.ShowError -> { - // TODO после задачи обработки ошибок - Toast.makeText( - this@CardsListActivity, - "ошибка!", - Toast.LENGTH_LONG + // TODO после задачи на диалог с ошибками + snackBarHelper.show( + "Произошла ошибка" ) - .show() } } - } } } @@ -221,12 +230,5 @@ internal class CardsListActivity : TransparentActivity() { } stubSubtitleView.setText(subTitleTextRes) stubButtonView.setText(buttonTextRes) - - stubButtonView.setOnClickListener { - viewModel.loadData( - savedCardsOptions.customer.customerKey, - options.features.showOnlyRecurrentCards - ) - } } } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt index 04779ab2..13a4d0c5 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt @@ -6,7 +6,7 @@ import ru.tinkoff.acquiring.sdk.redesign.cards.list.models.CardItemUiModel * Created by Ivan Golovachev */ sealed class CardsListState(val mode: CardListMode, val isInternal: Boolean = false) { - object Loading : CardsListState(CardListMode.STUB) + object Shimmer : CardsListState(CardListMode.STUB) object Empty : CardsListState(CardListMode.STUB) object Error : CardsListState(CardListMode.STUB) object NoNetwork : CardsListState(CardListMode.STUB) @@ -14,12 +14,13 @@ sealed class CardsListState(val mode: CardListMode, val isInternal: Boolean = fa class Content( mode: CardListMode, isInternal: Boolean, - val cards: List, + val cards: List ) : CardsListState(mode, isInternal) } sealed class CardListEvent { - class RemoveCard(val indexAt: Int) : CardListEvent() + object RemoveCardProgress : CardListEvent() + class RemoveCardSuccess(val indexAt: Int?) : CardListEvent() object ShowError : CardListEvent() } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBar.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBar.kt deleted file mode 100644 index ac7b06d7..00000000 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBar.kt +++ /dev/null @@ -1,42 +0,0 @@ -package ru.tinkoff.acquiring.sdk.utils - -import android.view.LayoutInflater -import android.view.View -import android.widget.TextView -import com.google.android.material.snackbar.Snackbar -import com.google.android.material.snackbar.Snackbar.SnackbarLayout -import ru.tinkoff.acquiring.sdk.R - - -/** - * Created by Ivan Golovachev - */ -// TODO до конца не ясно что он должен из себя предсатвлять - -class AcqSnackBar { - - fun show(view: View, textValue: String) { - val snackbar = Snackbar.make(view, "", Snackbar.LENGTH_LONG) - - // inflate the custom_snackbar_view created previously - - // inflate the custom_snackbar_view created previously - - val customSnackView: View = LayoutInflater.from(view.context).inflate(R.layout.acq_snackbar_layout, null) - val textView = customSnackView.findViewById(R.id.acq_snackbar_text) - textView.text = textValue - - // set the background of the default snackbar as transparent - - // now change the layout of the snackbar - - // now change the layout of the snackbar - val snackbarLayout = snackbar.view as SnackbarLayout - - // set padding of the all corners as 0 - - // set padding of the all corners as 0 - snackbarLayout.setPadding(0, 0, 0, 0) - snackbar.show() - } -} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBarHelper.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBarHelper.kt new file mode 100644 index 00000000..c977b7be --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBarHelper.kt @@ -0,0 +1,54 @@ +package ru.tinkoff.acquiring.sdk.utils + +import android.view.LayoutInflater +import android.view.View +import android.widget.ProgressBar +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import com.google.android.material.snackbar.Snackbar +import com.google.android.material.snackbar.Snackbar.SnackbarLayout +import ru.tinkoff.acquiring.sdk.R + + +/** + * Created by Ivan Golovachev + */ +class AcqSnackBarHelper(private val view: View) { + + private var snackbar: Snackbar? = null + + fun show(textValue: String, showProgressBar: Boolean = false) { + snackbar?.takeIf { it.isShown }?.dismiss() + val bar = Snackbar.make(view, "", Snackbar.LENGTH_INDEFINITE).apply { snackbar = this } + val customSnackView: View = + LayoutInflater.from(view.context).inflate(R.layout.acq_snackbar_layout, null) + val textView = customSnackView.findViewById(R.id.acq_snackbar_text) + val progressBar = customSnackView.findViewById(R.id.acq_snackbar_progress_bar) + val snackbarLayout = bar.view as SnackbarLayout + textView.text = textValue + progressBar.isVisible = showProgressBar + + snackbarLayout.addView(customSnackView, 0) + + snackbarLayout.setBackgroundColor( + ContextCompat.getColor(view.context, android.R.color.transparent) + ) + snackbarLayout.setPadding( + view.context.dpToPx(16), + 0, + view.context.dpToPx(16), + view.context.dpToPx(24) + ) + + bar.show() + } + + fun show(textValue: Int, showProgressBar: Boolean = false) { + show(view.context.getString(textValue), showProgressBar) + } + + fun hide() { + snackbar?.dismiss() + } +} \ No newline at end of file diff --git a/ui/src/main/res/drawable/acq_ic_cardlist_delete.xml b/ui/src/main/res/drawable/acq_ic_cardlist_delete.xml new file mode 100644 index 00000000..1d71a949 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_cardlist_delete.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/ui/src/main/res/drawable/acq_snackbar_bg.xml b/ui/src/main/res/drawable/acq_snackbar_bg.xml index 33a9a949..9647473b 100644 --- a/ui/src/main/res/drawable/acq_snackbar_bg.xml +++ b/ui/src/main/res/drawable/acq_snackbar_bg.xml @@ -17,6 +17,6 @@ - + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_activity_card_list.xml b/ui/src/main/res/layout/acq_activity_card_list.xml index f5df81cd..d24f476f 100644 --- a/ui/src/main/res/layout/acq_activity_card_list.xml +++ b/ui/src/main/res/layout/acq_activity_card_list.xml @@ -14,6 +14,7 @@ ~ limitations under the License. --> - + android:src="@drawable/acq_ic_cardlist_delete" /> \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_snackbar_layout.xml b/ui/src/main/res/layout/acq_snackbar_layout.xml index 8412b56c..1a3d67fd 100644 --- a/ui/src/main/res/layout/acq_snackbar_layout.xml +++ b/ui/src/main/res/layout/acq_snackbar_layout.xml @@ -3,22 +3,36 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:paddingHorizontal="8dp" - android:paddingVertical="16dp" + android:layout_height="wrap_content" android:background="@drawable/acq_snackbar_bg" - android:layout_height="wrap_content"> + android:paddingHorizontal="16dp" + android:paddingVertical="16dp"> + + + app:layout_constraintTop_toTopOf="parent" + tools:text="Карта •7913 удалена" /> \ No newline at end of file diff --git a/ui/src/main/res/values-ru/strings.xml b/ui/src/main/res/values-ru/strings.xml index 6cefd9cb..6097a7e3 100644 --- a/ui/src/main/res/values-ru/strings.xml +++ b/ui/src/main/res/values-ru/strings.xml @@ -42,6 +42,9 @@ Код Добавить + Карта •%1$s добавлена + Карта •%1$s удалена + Удаляем карту Доступен %d символ diff --git a/ui/src/main/res/values/colors.xml b/ui/src/main/res/values/colors.xml index 758ee6d9..885409b8 100644 --- a/ui/src/main/res/values/colors.xml +++ b/ui/src/main/res/values/colors.xml @@ -57,5 +57,6 @@ #333333 #333333 + #FFDD2D diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index a0bf0b8b..818acd31 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -45,6 +45,10 @@ This is where your cards will be Add + Card •%1$s added + Card •%1$s deleted + delete in progress + %d symbol available %d symbols available diff --git a/ui/src/main/res/values/styles.xml b/ui/src/main/res/values/styles.xml index 4eaf9fc2..e31bf53f 100644 --- a/ui/src/main/res/values/styles.xml +++ b/ui/src/main/res/values/styles.xml @@ -21,7 +21,6 @@ @color/acq_colorMainDark @color/acq_colorAccent @color/acq_colorText - @color/acq_colorAccent @style/AcqMenuItem @style/AcquiringDialogStyle @@ -257,4 +256,8 @@ 16dp + + diff --git a/ui/src/test/java/TurbineExt.kt b/ui/src/test/java/TurbineExt.kt index a296b2ef..865d9edb 100644 --- a/ui/src/test/java/TurbineExt.kt +++ b/ui/src/test/java/TurbineExt.kt @@ -1,5 +1,6 @@ - +import app.cash.turbine.ReceiveTurbine import kotlinx.coroutines.delay +import org.junit.Assert /** * Created by Ivan Golovachev @@ -8,4 +9,13 @@ import kotlinx.coroutines.delay //почему то всегда нужно немного подождать перед взамодействием с турбиной..... internal suspend fun turbineDelay() { delay(10) -} \ No newline at end of file +} + +suspend fun ReceiveTurbine.awaitWithConditionOrNext(condition: (T) -> Boolean) { + val item = awaitItem() + if (condition(item)) { + Assert.assertTrue(condition(item)) + } else { + awaitWithConditionOrNext(condition) + } +} diff --git a/ui/src/test/java/common/AssertExt.kt b/ui/src/test/java/common/AssertExt.kt new file mode 100644 index 00000000..d6dc8193 --- /dev/null +++ b/ui/src/test/java/common/AssertExt.kt @@ -0,0 +1,14 @@ +package common + +import org.junit.Assert + +/** + * Created by Ivan Golovachev + */ +fun assertByClassName(expected: Any?, actual: Any?) { + Assert.assertEquals(expected?.javaClass?.simpleName, actual?.javaClass?.simpleName) +} + +fun assertByClassName(expected: Class<*>, actual: Class<*>) { + Assert.assertEquals(expected?.javaClass?.simpleName, actual?.javaClass?.simpleName) +} \ No newline at end of file diff --git a/ui/src/test/java/common/CollectDataExt.kt b/ui/src/test/java/common/CollectDataExt.kt new file mode 100644 index 00000000..fe8f4c1f --- /dev/null +++ b/ui/src/test/java/common/CollectDataExt.kt @@ -0,0 +1,53 @@ +package common + +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectIndexed +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import org.junit.Assert + +/** + * Created by Ivan Golovachev + */ + +internal class MutableCollector( + private val f: Flow +) { + + private val values = mutableListOf() + val flow: Flow = flow { + values.forEach { it?.let { emit(it) } } + } + private val scope = CoroutineScope(Dispatchers.IO) + lateinit var collectJob: Job + var expectedCount: Int = 0 + + fun takeValues(expectedCount: Int) { + this.expectedCount = expectedCount + collectJob = scope.launch { + f.collect { + if (it != null) { + values += it + } + if (values.size == expectedCount) { + collectJob.cancel() + } + if (values.size > expectedCount) { + throw IllegalStateException("expected values are $expectedCount, incomed - ${values.size} \n$values") + } + } + } + } + + suspend fun joinWithTimeout(timeout: Long = 1000) { + var waiting = 0L + val step = 333L + while (collectJob.isActive && timeout > waiting) { + delay(step) + waiting += step + } + Assert.assertEquals(expectedCount, values.size) + } +} + diff --git a/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsDeleteViewModelTest.kt b/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsDeleteViewModelTest.kt index b2b655c0..25d2f6a3 100644 --- a/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsDeleteViewModelTest.kt +++ b/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsDeleteViewModelTest.kt @@ -1,13 +1,13 @@ package ru.tinkoff.acquiring.sdk.redesign.cards.list import app.cash.turbine.test -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers +import awaitWithConditionOrNext +import common.MutableCollector +import common.assertByClassName +import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.last -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import org.junit.Assert import org.junit.Test import org.mockito.kotlin.any @@ -25,8 +25,8 @@ import ru.tinkoff.acquiring.sdk.responses.RemoveCardResponse import ru.tinkoff.acquiring.sdk.utils.ConnectionChecker import ru.tinkoff.acquiring.sdk.utils.CoroutineManager import ru.tinkoff.acquiring.sdk.utils.RequestResult -import turbineDelay import java.lang.Exception +import java.util.concurrent.Executors /** * Created by Ivan Golovachev @@ -44,61 +44,91 @@ internal class CardsDeleteViewModelTest { @Test fun `when card delete complete`() = runBlocking { with(Environment(initState = defaultContent)) { + eventCollector.takeValues(2) setResponse(RequestResult.Success(RemoveCardResponse(1))) + vm.deleteCard(createCard("1"), "") - checkState() - checkEvent() + + eventCollector.joinWithTimeout() + eventCollector.flow.test { + assertByClassName(CardListEvent.RemoveCardProgress, awaitItem()) + assertByClassName(CardListEvent.RemoveCardSuccess(null), awaitItem()) + awaitComplete() + } } } @Test fun `when card delete throw error`() = runBlocking { with(Environment(initState = defaultContent)) { + + eventCollector.takeValues(2) setResponse(RequestResult.Failure(Exception())) + vm.deleteCard(createCard("1"), "") - checkEvent() - checkState() + + eventCollector.joinWithTimeout() + eventCollector.flow.test { + assertByClassName(CardListEvent.RemoveCardProgress, awaitItem()) + assertByClassName(CardListEvent.ShowError, awaitItem()) + awaitComplete() + } } } @Test - fun `when card delete is offline`() = runBlocking { + fun `when card delete without key`() = runBlocking { with(Environment(initState = defaultContent)) { + + eventCollector.takeValues(1) setResponse(RequestResult.Failure(Exception())) - setOnline(true) - vm.deleteCard(createCard("1"), "") - checkEvent() - checkState() - } + vm.deleteCard(createCard("1"), null) + + eventCollector.joinWithTimeout() + eventCollector.flow.test { + assertByClassName(CardListEvent.ShowError, awaitItem()) + awaitComplete() + } + } } @Test - fun `when card delete multiply show empty`() = runBlocking { + fun `when card delete is offline`() = runBlocking { with(Environment(initState = defaultContent)) { - setResponse(RequestResult.Success(RemoveCardResponse(1))) - vm.deleteCard(createCard("1"), "") - checkEvent() - setResponse(RequestResult.Success(RemoveCardResponse(2))) - vm.deleteCard(createCard("2"), "") - checkEvent() - checkState() + eventCollector.takeValues(1) + setResponse(RequestResult.Failure(Exception())) + setOnline(false) + + vm.deleteCard(createCard("1"), "") + eventCollector.joinWithTimeout() + eventCollector.flow.test { + assertByClassName(CardListEvent.ShowError, awaitItem()) + awaitComplete() + } } } @Test fun `when card delete multiply show last card`() = runBlocking { with(Environment(initState = extendsContent)) { + + stateCollector.takeValues(2) + setResponse(RequestResult.Success(RemoveCardResponse(1))) vm.deleteCard(createCard("1"), "") - checkEvent() + setResponse(RequestResult.Success(RemoveCardResponse(2))) vm.deleteCard(createCard("2"), "") - checkEvent() - checkState() + stateCollector.joinWithTimeout() + stateCollector.flow.test { + assertByClassName(CardsListState.Content::class.java, awaitItem().javaClass) + assertByClassName(CardsListState.Content::class.java, awaitItem().javaClass) + awaitComplete() + } } } @@ -108,7 +138,8 @@ internal class CardsDeleteViewModelTest { val connectionMock: ConnectionChecker = mock { on { isOnline() } doReturn true }, val asdk: AcquiringSdk = mock { } ) { - val dispatcher: CoroutineDispatcher = Dispatchers.Default + val dispatcher: CoroutineDispatcher = + Executors.newSingleThreadExecutor().asCoroutineDispatcher() val vm = CardsListViewModel( asdk, @@ -118,6 +149,10 @@ internal class CardsDeleteViewModelTest { stateFlow.value = initState } + val eventCollector = MutableCollector(vm.eventFlow) + + val stateCollector = MutableCollector(vm.stateFlow) + fun setState(initState: CardsListState) { vm.stateFlow.value = initState } @@ -133,33 +168,6 @@ internal class CardsDeleteViewModelTest { whenever(asdk.removeCard(any())).doReturn(request) } - - suspend inline fun checkState() { - vm.stateFlow.test { - val value = awaitItem() - - Assert.assertTrue( - "state instance is ${value.javaClass.simpleName}\n expected is ${T::class.simpleName}", - value is T - ) - cancelAndIgnoreRemainingEvents() - } - } - - - suspend inline fun checkEvent() { - vm.eventFlow.filterNotNull().test { - awaitItem().let { - val event = it - - Assert.assertTrue( - "state instance is ${event?.javaClass?.simpleName}\n expected is ${T::class.simpleName}", - event is T - ) - cancelAndIgnoreRemainingEvents() - } - } - } } private fun createCard(idMock: String): CardItemUiModel = mock { on { id } doReturn idMock } From 11d2750f2cb0135068524b0d2aa6b901a130aa99 Mon Sep 17 00:00:00 2001 From: "i.khafizov" Date: Wed, 16 Nov 2022 19:08:38 +0400 Subject: [PATCH 013/126] MC-7129 bank issuers tests --- ui/src/test/java/BankIssuerTests.kt | 50 +++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 ui/src/test/java/BankIssuerTests.kt diff --git a/ui/src/test/java/BankIssuerTests.kt b/ui/src/test/java/BankIssuerTests.kt new file mode 100644 index 00000000..bf201992 --- /dev/null +++ b/ui/src/test/java/BankIssuerTests.kt @@ -0,0 +1,50 @@ +import org.junit.Test +import ru.tinkoff.acquiring.sdk.utils.BankIssuer +import ru.tinkoff.acquiring.sdk.utils.BankIssuer.ALFABANK +import ru.tinkoff.acquiring.sdk.utils.BankIssuer.GAZPROMBANK +import ru.tinkoff.acquiring.sdk.utils.BankIssuer.OTHER +import ru.tinkoff.acquiring.sdk.utils.BankIssuer.RAIFFEISEN +import ru.tinkoff.acquiring.sdk.utils.BankIssuer.SBERBANK +import ru.tinkoff.acquiring.sdk.utils.BankIssuer.TINKOFF +import ru.tinkoff.acquiring.sdk.utils.BankIssuer.UNKNOWN +import ru.tinkoff.acquiring.sdk.utils.BankIssuer.VTB + +class BankIssuerTests { + + @Test + fun verifyBankIssuers() { + cardNumbers.forEach { (number, bankIssuer) -> + assert(BankIssuer.resolve(number) == bankIssuer) + } + } + + companion object { + + val cardNumbers = listOf( + "" to UNKNOWN, + "4274" to UNKNOWN, + "4274029" to SBERBANK, + "427402" to SBERBANK, + "427920213213" to SBERBANK, + "54792721479214724" to SBERBANK, + "515775" to VTB, + "5257873423" to VTB, + "5543633423213" to VTB, + "415428" to ALFABANK, + "43141732432" to ALFABANK, + "477960324323532" to ALFABANK, + "220070" to TINKOFF, + "51890112412" to TINKOFF, + "5389942143435" to TINKOFF, + "402178" to RAIFFEISEN, + "46272923432" to RAIFFEISEN, + "5288093842487532" to RAIFFEISEN, + "404136" to GAZPROMBANK, + "48741521423" to GAZPROMBANK, + "529278345353" to GAZPROMBANK, + "247626" to OTHER, + "789384" to OTHER, + "218426" to OTHER, + ) + } +} \ No newline at end of file From 868a54e32e37467241bdcf49ffdec5d0ef71996b Mon Sep 17 00:00:00 2001 From: jqwout Date: Wed, 16 Nov 2022 22:22:28 +0300 Subject: [PATCH 014/126] =?UTF-8?q?MC-7275=20-=20=D1=82=D0=B5=D0=BA=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=D1=8B=D0=B5=20=D1=80=D0=B5=D1=81=D1=83=D1=80?= =?UTF-8?q?=D1=81=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cards/list/adapters/CardsListAdapter.kt | 2 +- .../cards/list/ui/CardsListActivity.kt | 18 ++++++------- .../main/res/layout/acq_card_list_content.xml | 2 +- ui/src/main/res/layout/acq_card_list_stub.xml | 9 ++++--- ui/src/main/res/menu/acq_card_list_menu.xml | 4 +-- ui/src/main/res/values-ru/strings.xml | 27 +++++++++++-------- ui/src/main/res/values/strings.xml | 24 ++++++++++------- 7 files changed, 48 insertions(+), 38 deletions(-) diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt index 24ecbfcd..49232201 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt @@ -79,7 +79,7 @@ class CardsListAdapter( onDeleteClick: (CardItemUiModel) -> Unit ) { cardNameView.text = itemView.context.getString( - R.string.card_list_item_card_name_masked_template, + R.string.acq_cardlist_bankname, card.bankName, card.tail ) diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt index 12604096..f4df6e9a 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt @@ -99,7 +99,7 @@ internal class CardsListActivity : TransparentActivity() { setSupportActionBar(findViewById(R.id.acq_toolbar)) supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayShowHomeEnabled(true) - supportActionBar?.setTitle(R.string.acq_card_list_title) + supportActionBar?.setTitle(R.string.acq_cardlist_title) } private fun initViews() { @@ -148,9 +148,9 @@ internal class CardsListActivity : TransparentActivity() { is CardsListState.Error -> { showStub( imageResId = R.drawable.acq_ic_cards_list_error_stub, - titleTextRes = R.string.acq_cards_list_error_title, - subTitleTextRes = R.string.acq_cards_list_error_subtitle, - buttonTextRes = R.string.acq_cards_list_error_button + titleTextRes = R.string.acq_cardlist_alert_label, + subTitleTextRes = R.string.acq_cardlist_stub_description, + buttonTextRes = R.string.acq_cardlist_alert_access ) stubButtonView.setOnClickListener { finish() @@ -160,8 +160,8 @@ internal class CardsListActivity : TransparentActivity() { showStub( imageResId = R.drawable.acq_ic_cards_list_empty, titleTextRes = null, - subTitleTextRes = R.string.acq_cards_list_empty_subtitle, - buttonTextRes = R.string.acq_cards_list_empty_button + subTitleTextRes = R.string.acq_cardlist_description, + buttonTextRes = R.string.acq_cardlist_button_add ) stubButtonView.setOnClickListener { //todo навигация с результатом о привязке карты @@ -170,9 +170,9 @@ internal class CardsListActivity : TransparentActivity() { is CardsListState.NoNetwork -> { showStub( imageResId = R.drawable.acq_ic_no_network, - titleTextRes = R.string.acq_cards_list_no_network_title, - subTitleTextRes = R.string.acq_cards_list_no_network_subtitle, - buttonTextRes = R.string.acq_cards_list_no_network_button + titleTextRes = R.string.acq_cardlist_stubnet_title, + subTitleTextRes = R.string.acq_cardlist_stubnet_description, + buttonTextRes = R.string.acq_cardlist_button_stubnet ) stubButtonView.setOnClickListener { viewModel.loadData( diff --git a/ui/src/main/res/layout/acq_card_list_content.xml b/ui/src/main/res/layout/acq_card_list_content.xml index f8470541..84faac9a 100644 --- a/ui/src/main/res/layout/acq_card_list_content.xml +++ b/ui/src/main/res/layout/acq_card_list_content.xml @@ -43,7 +43,7 @@ android:fontFamily="@font/roboto_regular" android:gravity="center_vertical" android:padding="16dp" - android:text="Добавить новую" + android:text="@string/acq_cardlist_addcard" android:textSize="@dimen/acq_large_text_size" app:drawableStartCompat="@drawable/acq_add_new_card"> diff --git a/ui/src/main/res/layout/acq_card_list_stub.xml b/ui/src/main/res/layout/acq_card_list_stub.xml index a4546454..9a86679d 100644 --- a/ui/src/main/res/layout/acq_card_list_stub.xml +++ b/ui/src/main/res/layout/acq_card_list_stub.xml @@ -16,7 +16,8 @@ + android:layout_height="match_parent" + xmlns:tools="http://schemas.android.com/tools"> @@ -50,7 +51,7 @@ android:layout_height="wrap_content" android:layout_marginTop="8dp" android:gravity="center" - android:text="@string/acq_cards_list_no_network_subtitle" + tools:text="@string/acq_cardlist_stubnet_description" app:layout_constraintBottom_toTopOf="@+id/acq_stub_retry_button" app:layout_constraintTop_toBottomOf="@+id/acq_stub_title" /> @@ -63,7 +64,7 @@ android:gravity="center" android:paddingHorizontal="18dp" android:paddingVertical="14dp" - android:text="@string/acq_cards_list_no_network_button" + android:text="@string/acq_cardlist_button_stubnet" android:textAllCaps="false" android:textColor="@color/acq_colorAccent" android:textSize="@dimen/acq_small_text_size" diff --git a/ui/src/main/res/menu/acq_card_list_menu.xml b/ui/src/main/res/menu/acq_card_list_menu.xml index 8953885d..9a0e0bdc 100644 --- a/ui/src/main/res/menu/acq_card_list_menu.xml +++ b/ui/src/main/res/menu/acq_card_list_menu.xml @@ -6,11 +6,11 @@ \ No newline at end of file diff --git a/ui/src/main/res/values-ru/strings.xml b/ui/src/main/res/values-ru/strings.xml index 6097a7e3..da1e33f8 100644 --- a/ui/src/main/res/values-ru/strings.xml +++ b/ui/src/main/res/values-ru/strings.xml @@ -24,17 +24,19 @@ Выберите приложение - %1$s • %2$s - Ваши карты - - - Попробуйте снова через пару минут - Понятно - Не загрузилось - - Обновить - Здесь будут ваши карты - Добавить + %1$s • %2$s + Ваши карты + + + Попробуйте снова через пару минут + Понятно + + Не загрузилось + + Обновить + Здесь будут ваши карты + Добавить + Добавить новую Оплата картой Номер @@ -45,6 +47,9 @@ Карта •%1$s добавлена Карта •%1$s удалена Удаляем карту + Изменить + Готово + Доступен %d символ diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 818acd31..aba1feba 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -26,28 +26,32 @@ fps Tinkoff - %1$s • %2$s - Your cards + %1$s • %2$s + Your cards + Attach card Number Expiry date Code Attach - - Try again in a couple of minutes - Clear + + Try again in a couple of minutes + Clear - Not loaded - - Refresh + Not loaded + + Refresh - This is where your cards will be - Add + This is where your cards will be + Add Card •%1$s added Card •%1$s deleted delete in progress + Add new + Сhange + Done %d symbol available From 62ae62012f6081472d5c58fe8276f7609587fd8d Mon Sep 17 00:00:00 2001 From: jqwout Date: Thu, 17 Nov 2022 16:59:48 +0300 Subject: [PATCH 015/126] =?UTF-8?q?MC-7343=20-=20=D1=80=D0=B5=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=20=D0=BD=D0=BE=D1=83=D1=82=D1=81=20=D0=B2=20=D0=B0=D0=BF?= =?UTF-8?q?=D0=BF=D1=86=D0=B5=D0=BD=D1=82=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/app_center.yml | 2 +- .../app_center_release_notes_test.yml | 17 ++++++++++++++ .github/workflows/merge_request.yml | 11 +++++++++ build.gradle | 23 ++++++++++++++++--- 4 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/app_center_release_notes_test.yml diff --git a/.github/workflows/app_center.yml b/.github/workflows/app_center.yml index 3a7ce280..09c9364e 100644 --- a/.github/workflows/app_center.yml +++ b/.github/workflows/app_center.yml @@ -23,5 +23,5 @@ jobs: token: ${{secrets.APP_CENTER_TOKEN}} group: Collaborators file: sample/build/outputs/apk/debug/sample-debug.apk - notifyTesters: false + notifyTesters: true debug: false \ No newline at end of file diff --git a/.github/workflows/app_center_release_notes_test.yml b/.github/workflows/app_center_release_notes_test.yml new file mode 100644 index 00000000..c69838e3 --- /dev/null +++ b/.github/workflows/app_center_release_notes_test.yml @@ -0,0 +1,17 @@ +name: publish demo to AppCenter + +on: + workflow_dispatch: + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - name: Set the value + id: step_one + run: | + echo 'log<> $GITHUB_ENV + ./gradlew -q :diffWithMaster >> $GITHUB_ENV + echo 'EOF' >> $GITHUB_ENV \ No newline at end of file diff --git a/.github/workflows/merge_request.yml b/.github/workflows/merge_request.yml index 99fa5b13..e52bd247 100644 --- a/.github/workflows/merge_request.yml +++ b/.github/workflows/merge_request.yml @@ -5,7 +5,18 @@ on: branches: - 'master' - 'v**' + paths: + - ".github/workflows/app_center_release_notes_test.yaml" jobs: check: uses: tinkoff-mobile-tech/workflows/.github/workflows/android_lib.merge_request.yml@v1 + + log: + steps: + - name: Set the value + id: step_one + run: | + echo 'log<> $GITHUB_ENV + ./gradlew -q :diffWithMaster >> $GITHUB_ENV + echo 'EOF' >> $GITHUB_ENV \ No newline at end of file diff --git a/build.gradle b/build.gradle index 8a7176f2..4de21b55 100644 --- a/build.gradle +++ b/build.gradle @@ -1,12 +1,10 @@ -apply plugin: 'kotlin' -apply from: 'gradle/versions.gradle' - buildscript { apply from: 'gradle/versions.gradle' repositories { google() jcenter() + mavenCentral() maven { url 'https://plugins.gradle.org/m2/' } } @@ -17,6 +15,13 @@ buildscript { } } +plugins { + id 'org.ajoberstar.grgit' version '5.0.0' +} + +apply plugin: 'kotlin' +apply from: 'gradle/versions.gradle' + allprojects { repositories { jcenter() @@ -28,3 +33,15 @@ allprojects { } } } + +tasks.register("diffWithMaster") { + doLast { + def branch = grgit.branch.current().getName() + def log = grgit + .log { range branch, 'master' } + .findAll { !it.shortMessage.startsWith("Merge pull request ") } + .collect { it.shortMessage } + .join("\n") + print log + } +} \ No newline at end of file From 96d1665fcfa2d830631e4adbecd159fe4c10c100 Mon Sep 17 00:00:00 2001 From: "i.khafizov" Date: Thu, 17 Nov 2022 21:16:21 +0400 Subject: [PATCH 016/126] MC-7035 card logo, Results API for attach card --- .../acquiring/sample/ui/MainActivity.kt | 60 ++- .../main/res/layout/activity_card_logos.xml | 352 ++++++++++++++++++ .../tinkoff/acquiring/sdk/TinkoffAcquiring.kt | 26 ++ .../options/screen/AttachCardOptions.kt | 6 +- .../cards/list/adapters/CardsListAdapter.kt | 6 +- .../cards/list/models/CardItemUiModel.kt | 9 +- .../carddatainput/CardDataInputFragment.kt | 8 +- .../carddatainput/CardNumberFormatter.kt | 10 +- .../sdk/smartfield/BaubleCardLogo.kt | 33 ++ .../ui/customview/editcard/CardFormatter.kt | 2 +- .../customview/editcard/CardPaymentSystem.kt | 2 +- .../editcard/DefaultCardIconsHolder.kt | 2 +- .../sdk/ui/customview/editcard/EditCard.kt | 8 +- .../editcard/validators/CardValidator.kt | 2 +- .../sdk/ui/fragments/AttachCardFragment.kt | 1 - .../tinkoff/acquiring/sdk/utils/BankIssuer.kt | 8 +- .../sdk/utils/CardSystemIconsHolder.kt | 2 +- .../sdk/viewmodel/CardLogoProvider.kt | 93 +++++ .../acq_ic_card_maestro_unknown.xml | 20 + .../acq_ic_card_mastercard_unknown.xml | 20 + .../acq_ic_card_mir_unknown.xml | 19 + .../acq_ic_card_unionpay_unknown.xml | 24 ++ .../acq_ic_card_unknown_unknown.xml | 15 + .../acq_ic_card_visa_unknown.xml | 19 + .../drawable/acq_ic_card_maestro_alfabank.xml | 25 ++ .../acq_ic_card_maestro_gazprombank.xml | 34 ++ .../drawable/acq_ic_card_maestro_other.xml | 56 +++ .../acq_ic_card_maestro_raiffeisen.xml | 26 ++ .../drawable/acq_ic_card_maestro_sberbank.xml | 54 +++ .../drawable/acq_ic_card_maestro_tinkoff.xml | 26 ++ .../drawable/acq_ic_card_maestro_unknown.xml | 20 + .../res/drawable/acq_ic_card_maestro_vtb.xml | 26 ++ .../acq_ic_card_mastercard_alfabank.xml | 25 ++ .../acq_ic_card_mastercard_gazprombank.xml | 34 ++ .../drawable/acq_ic_card_mastercard_other.xml | 56 +++ .../acq_ic_card_mastercard_raiffeisen.xml | 26 ++ .../acq_ic_card_mastercard_sberbank.xml | 54 +++ .../acq_ic_card_mastercard_tinkoff.xml | 26 ++ .../acq_ic_card_mastercard_unknown.xml | 20 + .../drawable/acq_ic_card_mastercard_vtb.xml | 26 ++ .../res/drawable/acq_ic_card_mir_alfabank.xml | 20 + .../drawable/acq_ic_card_mir_gazprombank.xml | 25 ++ .../res/drawable/acq_ic_card_mir_other.xml | 51 +++ .../drawable/acq_ic_card_mir_raiffeisen.xml | 37 ++ .../res/drawable/acq_ic_card_mir_sberbank.xml | 45 +++ .../res/drawable/acq_ic_card_mir_tinkoff.xml | 21 ++ .../res/drawable/acq_ic_card_mir_unknown.xml | 35 ++ .../main/res/drawable/acq_ic_card_mir_vtb.xml | 21 ++ .../acq_ic_card_unionpay_alfabank.xml | 29 ++ .../acq_ic_card_unionpay_gazprombank.xml | 38 ++ .../drawable/acq_ic_card_unionpay_other.xml | 60 +++ .../acq_ic_card_unionpay_raiffeisen.xml | 30 ++ .../acq_ic_card_unionpay_sberbank.xml | 58 +++ .../drawable/acq_ic_card_unionpay_tinkoff.xml | 30 ++ .../drawable/acq_ic_card_unionpay_unknown.xml | 24 ++ .../res/drawable/acq_ic_card_unionpay_vtb.xml | 30 ++ .../drawable/acq_ic_card_unknown_unknown.xml | 15 + .../drawable/acq_ic_card_visa_alfabank.xml | 20 + .../drawable/acq_ic_card_visa_gazprombank.xml | 25 ++ .../res/drawable/acq_ic_card_visa_other.xml | 51 +++ .../drawable/acq_ic_card_visa_raiffeisen.xml | 21 ++ .../drawable/acq_ic_card_visa_sberbank.xml | 45 +++ .../res/drawable/acq_ic_card_visa_tinkoff.xml | 21 ++ .../res/drawable/acq_ic_card_visa_unknown.xml | 15 + .../res/drawable/acq_ic_card_visa_vtb.xml | 21 ++ ui/src/main/res/layout/acq_card_list_item.xml | 6 +- ui/src/main/res/layout/acq_item_card_logo.xml | 10 + ui/src/test/java/CardPaymentSystemTests.kt | 2 +- 68 files changed, 2022 insertions(+), 65 deletions(-) create mode 100644 sample/src/main/res/layout/activity_card_logos.xml create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/smartfield/BaubleCardLogo.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/CardLogoProvider.kt create mode 100644 ui/src/main/res/drawable-night/acq_ic_card_maestro_unknown.xml create mode 100644 ui/src/main/res/drawable-night/acq_ic_card_mastercard_unknown.xml create mode 100644 ui/src/main/res/drawable-night/acq_ic_card_mir_unknown.xml create mode 100644 ui/src/main/res/drawable-night/acq_ic_card_unionpay_unknown.xml create mode 100644 ui/src/main/res/drawable-night/acq_ic_card_unknown_unknown.xml create mode 100644 ui/src/main/res/drawable-night/acq_ic_card_visa_unknown.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_maestro_alfabank.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_maestro_gazprombank.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_maestro_other.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_maestro_raiffeisen.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_maestro_sberbank.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_maestro_tinkoff.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_maestro_unknown.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_maestro_vtb.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_mastercard_alfabank.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_mastercard_gazprombank.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_mastercard_other.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_mastercard_raiffeisen.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_mastercard_sberbank.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_mastercard_tinkoff.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_mastercard_unknown.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_mastercard_vtb.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_mir_alfabank.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_mir_gazprombank.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_mir_other.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_mir_raiffeisen.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_mir_sberbank.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_mir_tinkoff.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_mir_unknown.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_mir_vtb.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_unionpay_alfabank.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_unionpay_gazprombank.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_unionpay_other.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_unionpay_raiffeisen.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_unionpay_sberbank.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_unionpay_tinkoff.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_unionpay_unknown.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_unionpay_vtb.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_unknown_unknown.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_visa_alfabank.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_visa_gazprombank.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_visa_other.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_visa_raiffeisen.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_visa_sberbank.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_visa_tinkoff.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_visa_unknown.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_visa_vtb.xml create mode 100644 ui/src/main/res/layout/acq_item_card_logo.xml diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt index b817dbe4..d4f44ea0 100644 --- a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt @@ -25,9 +25,8 @@ import android.view.Menu import android.view.MenuItem import android.widget.ListView import android.widget.Toast +import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import ru.tinkoff.acquiring.sample.R import ru.tinkoff.acquiring.sample.SampleApplication import ru.tinkoff.acquiring.sample.adapters.BooksListAdapter @@ -36,15 +35,14 @@ import ru.tinkoff.acquiring.sample.models.BooksRegistry import ru.tinkoff.acquiring.sample.service.PaymentNotificationIntentService import ru.tinkoff.acquiring.sample.service.PriceNotificationReceiver import ru.tinkoff.acquiring.sample.utils.PaymentNotificationManager -import ru.tinkoff.acquiring.sample.utils.SessionParams import ru.tinkoff.acquiring.sample.utils.SettingsSdkManager import ru.tinkoff.acquiring.sample.utils.TerminalsManager +import ru.tinkoff.acquiring.sdk.TinkoffAcquiring import ru.tinkoff.acquiring.sdk.TinkoffAcquiring.Companion.EXTRA_CARD_ID import ru.tinkoff.acquiring.sdk.TinkoffAcquiring.Companion.RESULT_ERROR import ru.tinkoff.acquiring.sdk.localization.AsdkSource import ru.tinkoff.acquiring.sdk.localization.Language import ru.tinkoff.acquiring.sdk.models.options.FeaturesOptions -import ru.tinkoff.acquiring.sdk.models.options.screen.AttachCardOptions import ru.tinkoff.acquiring.sdk.models.options.screen.SavedCardsOptions import ru.tinkoff.acquiring.sdk.models.result.CardResult import ru.tinkoff.acquiring.sdk.threeds.ThreeDsHelper @@ -60,6 +58,10 @@ class MainActivity : AppCompatActivity(), BooksListAdapter.BookDetailsClickListe private val priceNotificationReceiver = PriceNotificationReceiver() private var selectedCardIdForDemo: String? = null + private val attachCard = registerForActivityResult(TinkoffAcquiring.AttachCardContract) { cardId -> + cardId?.let { PaymentResultActivity.start(this, it) } ?: toast(R.string.attachment_failed) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -150,18 +152,6 @@ class MainActivity : AppCompatActivity(), BooksListAdapter.BookDetailsClickListe Toast.makeText(this, R.string.payment_failed, Toast.LENGTH_SHORT).show() } } - ATTACH_CARD_REQUEST_CODE -> { - when (resultCode) { - RESULT_OK -> PaymentResultActivity.start(this, - data?.getStringExtra(EXTRA_CARD_ID)!!) - RESULT_CANCELED -> Toast.makeText(this, - R.string.attachment_cancelled, - Toast.LENGTH_SHORT).show() - RESULT_ERROR -> Toast.makeText(this, - R.string.attachment_failed, - Toast.LENGTH_SHORT).show() - } - } SAVED_CARDS_REQUEST_CODE -> { val selectedCardId = data?.getStringExtra(EXTRA_CARD_ID) selectedCardIdForDemo = selectedCardId @@ -217,25 +207,22 @@ class MainActivity : AppCompatActivity(), BooksListAdapter.BookDetailsClickListe val settings = SettingsSdkManager(this) val params = TerminalsManager.selectedTerminal - val options = AttachCardOptions() - .setOptions { - customerOptions { - customerKey = params.customerKey - checkType = settings.checkType - email = params.customerEmail - } - featuresOptions { - useSecureKeyboard = settings.isCustomKeyboardEnabled - validateExpiryDate = settings.validateExpiryDate - cameraCardScanner = settings.cameraScanner - darkThemeMode = settings.resolveDarkThemeMode() - theme = settings.resolveAttachCardStyle() - } - } + val options = SampleApplication.tinkoffAcquiring.attachCardOptions { + customerOptions { + customerKey = params.customerKey + checkType = settings.checkType + email = params.customerEmail + } + featuresOptions { + useSecureKeyboard = settings.isCustomKeyboardEnabled + validateExpiryDate = settings.validateExpiryDate + cameraCardScanner = settings.cameraScanner + darkThemeMode = settings.resolveDarkThemeMode() + theme = settings.resolveAttachCardStyle() + } + } - SampleApplication.tinkoffAcquiring.openAttachCardScreen(this, - options, - ATTACH_CARD_REQUEST_CODE) + attachCard.launch(options) } private fun openStaticQrScreen() { @@ -276,7 +263,6 @@ class MainActivity : AppCompatActivity(), BooksListAdapter.BookDetailsClickListe companion object { - private const val ATTACH_CARD_REQUEST_CODE = 11 private const val STATIC_QR_REQUEST_CODE = 12 private const val SAVED_CARDS_REQUEST_CODE = 13 const val NOTIFICATION_PAYMENT_REQUEST_CODE = 14 @@ -285,5 +271,9 @@ class MainActivity : AppCompatActivity(), BooksListAdapter.BookDetailsClickListe fun Activity.toast(message: String) = runOnUiThread { Toast.makeText(this, message, Toast.LENGTH_SHORT).show() } + + fun Activity.toast(@StringRes message: Int) = runOnUiThread { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } } } diff --git a/sample/src/main/res/layout/activity_card_logos.xml b/sample/src/main/res/layout/activity_card_logos.xml new file mode 100644 index 00000000..3bcd4a43 --- /dev/null +++ b/sample/src/main/res/layout/activity_card_logos.xml @@ -0,0 +1,352 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt index bc82a183..da6ae18b 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt @@ -20,6 +20,8 @@ import android.app.Activity import android.app.PendingIntent import android.content.Context import android.content.Intent +import androidx.activity.result.contract.ActivityResultContract +import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -222,6 +224,12 @@ class TinkoffAcquiring( * @param options настройки привязки карты и визуального отображения экрана * @param requestCode код для получения результата, по завершению работы экрана Acquiring SDK */ + @Deprecated("registerForActivityResult(AttachCardContract) { cardId -> }.launch(options)", + ReplaceWith("registerForActivityResult(AttachCardContract) { cardId ->\n" + + " // handle result\n" + + "}.launch(attachCardOptions {\n" + + " //setup options\n" + + "})")) fun openAttachCardScreen(activity: Activity, options: AttachCardOptions, requestCode: Int) { val intent = prepareIntent(activity, options, AttachCardActivity::class.java) activity.startActivityForResult(intent, requestCode) @@ -438,6 +446,24 @@ class TinkoffAcquiring( return BaseAcquiringActivity.createIntent(context, options, cls) } + fun attachCardOptions(setup: AttachCardOptions.() -> Unit) = AttachCardOptions().also { options -> + options.setTerminalParams(terminalKey, publicKey) + setup(options) + } + + object AttachCardContract : ActivityResultContract() { + + override fun createIntent(context: Context, input: AttachCardOptions): Intent = + BaseAcquiringActivity.createIntent(context, input.apply { + setTerminalParams(terminalKey, publicKey) + }, AttachCardActivity::class.java) + + override fun parseResult(resultCode: Int, intent: Intent?): String? = when (resultCode) { + AppCompatActivity.RESULT_OK -> intent?.getStringExtra(EXTRA_CARD_ID)!! + else -> null + } + } + companion object { const val RESULT_ERROR = 500 diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/models/options/screen/AttachCardOptions.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/models/options/screen/AttachCardOptions.kt index 92dc7eb7..2519528c 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/models/options/screen/AttachCardOptions.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/models/options/screen/AttachCardOptions.kt @@ -18,6 +18,7 @@ package ru.tinkoff.acquiring.sdk.models.options.screen import android.os.Parcel import android.os.Parcelable +import ru.tinkoff.acquiring.sdk.TinkoffAcquiring import ru.tinkoff.acquiring.sdk.exceptions.AcquiringSdkException /** @@ -27,7 +28,10 @@ import ru.tinkoff.acquiring.sdk.exceptions.AcquiringSdkException */ class AttachCardOptions : BaseCardsOptions, Parcelable { - constructor() : super() + /** + * [TinkoffAcquiring.attachCardOptions] + */ + internal constructor() : super() private constructor(parcel: Parcel) : super(parcel) override fun setOptions(options: AttachCardOptions.() -> Unit): AttachCardOptions { diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt index 49232201..497c3aeb 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt @@ -10,6 +10,7 @@ import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import ru.tinkoff.acquiring.sdk.R import ru.tinkoff.acquiring.sdk.redesign.cards.list.models.CardItemUiModel +import ru.tinkoff.acquiring.sdk.viewmodel.CardLogoProvider /** * Created by Ivan Golovachev @@ -69,6 +70,8 @@ class CardsListAdapter( class CardViewHolder(view: View) : RecyclerView.ViewHolder(view) { + private val cardLogo = + itemView.findViewById(R.id.acq_card_list_item_logo) private val cardNameView = itemView.findViewById(R.id.acq_card_list_item_masked_name) private val cardDeleteIcon = @@ -78,9 +81,10 @@ class CardsListAdapter( card: CardItemUiModel, onDeleteClick: (CardItemUiModel) -> Unit ) { + cardLogo.setImageResource(CardLogoProvider.getCardLogo(card.pan)) cardNameView.text = itemView.context.getString( R.string.acq_cardlist_bankname, - card.bankName, + card.bankName(itemView.context).orEmpty(), card.tail ) bindDeleteVisibility(card.showDelete) diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/models/CardItemUiModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/models/CardItemUiModel.kt index a7441192..eb08bb49 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/models/CardItemUiModel.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/models/CardItemUiModel.kt @@ -1,13 +1,12 @@ package ru.tinkoff.acquiring.sdk.redesign.cards.list.models +import android.content.Context import ru.tinkoff.acquiring.sdk.models.Card +import ru.tinkoff.acquiring.sdk.utils.BankIssuer data class CardItemUiModel( private val card: Card, - // TODO after brandByBin algo impl - val bankName: String = "***", - // TODO after delete card task val showDelete: Boolean = false, @@ -15,5 +14,9 @@ data class CardItemUiModel( ) { val id = card.cardId + val pan: String? = card.pan + val tail = card.pan?.takeLast(4) + + fun bankName(context: Context) = BankIssuer.resolve(pan).getCaption(context) } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt index f3b7d0b2..c50e6510 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt @@ -15,6 +15,7 @@ import ru.tinkoff.acquiring.sdk.R import ru.tinkoff.acquiring.sdk.cardscanners.CameraCardScanner import ru.tinkoff.acquiring.sdk.cardscanners.CardScanner import ru.tinkoff.acquiring.sdk.smartfield.AcqTextFieldView +import ru.tinkoff.acquiring.sdk.smartfield.BaubleCardLogo import ru.tinkoff.acquiring.sdk.smartfield.BaubleClearButton import ru.tinkoff.acquiring.sdk.ui.customview.editcard.CardPaymentSystem import ru.tinkoff.acquiring.sdk.ui.customview.editcard.CardPaymentSystem.MASTER_CARD @@ -37,7 +38,7 @@ internal class CardDataInputFragment : Fragment() { val expiryDateInput: AcqTextFieldView by lazyView(R.id.expiry_date_input) val cvcInput: AcqTextFieldView by lazyView(R.id.cvc_input) - val cardNumber get() = CardNumberFormatter.normalizeCardNumber(cardNumberInput.text) + val cardNumber get() = CardNumberFormatter.normalize(cardNumberInput.text) val expiryDate get() = expiryDateInput.text.orEmpty() val cvc get() = cvcInput.text.orEmpty() @@ -48,6 +49,7 @@ internal class CardDataInputFragment : Fragment() { super.onViewCreated(view, savedInstanceState) with(card_number_input) { + BaubleCardLogo().attach(this) BaubleClearButton().attach(this) val cardNumberFormatter = CardNumberFormatter().also { editText.addTextChangedListener(it) @@ -57,7 +59,7 @@ internal class CardDataInputFragment : Fragment() { errorHighlighted = false val cardNumber = cardNumber - val paymentSystem = CardPaymentSystem.resolvePaymentSystem(cardNumber) + val paymentSystem = CardPaymentSystem.resolve(cardNumber) if (cardNumber.startsWith("0")) { errorHighlighted = true @@ -72,8 +74,6 @@ internal class CardDataInputFragment : Fragment() { expiry_date_input.requestViewFocus() } } - - // logo } } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardNumberFormatter.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardNumberFormatter.kt index 6eb57474..b0aced4e 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardNumberFormatter.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardNumberFormatter.kt @@ -13,7 +13,7 @@ internal class CardNumberFormatter : TextWatcher { private var deleteAt = -1 override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { - prev = normalizeCardNumber(s?.toString()) + prev = normalize(s?.toString()) } override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { @@ -24,12 +24,12 @@ internal class CardNumberFormatter : TextWatcher { override fun afterTextChanged(source: Editable) { if (selfChange) return - var cardNumber = normalizeCardNumber(source.toString()) + var cardNumber = normalize(source.toString()) if (cardNumber == prev && deleteAt != -1) { - cardNumber = normalizeCardNumber(source.toString().removeRange(deleteAt - 1, deleteAt)) + cardNumber = normalize(source.toString().removeRange(deleteAt - 1, deleteAt)) } - val paymentSystem = CardPaymentSystem.resolvePaymentSystem(cardNumber) + val paymentSystem = CardPaymentSystem.resolve(cardNumber) if (cardNumber.length > paymentSystem.range.last) { cardNumber = cardNumber.substring(0, paymentSystem.range.last) @@ -60,6 +60,6 @@ internal class CardNumberFormatter : TextWatcher { private val REGEX_NON_DIGITS = "\\D".toRegex() - fun normalizeCardNumber(source: String?) = source.orEmpty().replace(REGEX_NON_DIGITS, "") + fun normalize(source: String?) = source.orEmpty().replace(REGEX_NON_DIGITS, "") } } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/smartfield/BaubleCardLogo.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/smartfield/BaubleCardLogo.kt new file mode 100644 index 00000000..8cd3f348 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/smartfield/BaubleCardLogo.kt @@ -0,0 +1,33 @@ +package ru.tinkoff.acquiring.sdk.smartfield + +import android.annotation.SuppressLint +import android.view.LayoutInflater +import android.widget.ImageView +import ru.tinkoff.acquiring.sdk.R +import ru.tinkoff.acquiring.sdk.redesign.common.carddatainput.CardNumberFormatter +import ru.tinkoff.acquiring.sdk.utils.SimpleTextWatcher +import ru.tinkoff.acquiring.sdk.viewmodel.CardLogoProvider + +internal class BaubleCardLogo { + + private lateinit var textFieldView: AcqTextFieldView + private lateinit var view: ImageView + + @SuppressLint("InflateParams") + fun attach(textFieldView: AcqTextFieldView) { + this.textFieldView = textFieldView + + val context = textFieldView.context + view = LayoutInflater.from(context).inflate(R.layout.acq_item_card_logo, null) as ImageView + + textFieldView.addLeftBauble(view) + textFieldView.editText.addTextChangedListener(SimpleTextWatcher.after { update() }) + + update() + } + + private fun update() { + view.setImageResource(CardLogoProvider.getCardLogo( + CardNumberFormatter.normalize(textFieldView.text))) + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/CardFormatter.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/CardFormatter.kt index caf6e5e4..9d138d76 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/CardFormatter.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/CardFormatter.kt @@ -43,7 +43,7 @@ internal object CardFormatter { fun resolveCardNumberMask(cardNumber: String): String { val length = cardNumber.length - return when (CardPaymentSystem.resolvePaymentSystem(cardNumber)) { + return when (CardPaymentSystem.resolve(cardNumber)) { VISA -> "#### #### #### ####" MASTER_CARD -> "#### #### #### ####" MIR -> when (length) { diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/CardPaymentSystem.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/CardPaymentSystem.kt index ac940a16..aca4f86d 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/CardPaymentSystem.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/CardPaymentSystem.kt @@ -44,7 +44,7 @@ enum class CardPaymentSystem(val regex: Regex, val range: IntRange, val showLogo companion object { - fun resolvePaymentSystem(cardNumber: String): CardPaymentSystem = + fun resolve(cardNumber: String): CardPaymentSystem = values().find { it.matches(cardNumber) } ?: UNKNOWN } } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/DefaultCardIconsHolder.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/DefaultCardIconsHolder.kt index 3c6f7409..836858d3 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/DefaultCardIconsHolder.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/DefaultCardIconsHolder.kt @@ -35,7 +35,7 @@ import ru.tinkoff.acquiring.sdk.ui.customview.editcard.CardPaymentSystem.VISA internal class DefaultCardIconsHolder(private val context: Context) : EditCardSystemIconsHolder { override fun getCardSystemLogo(cardNumber: String): Bitmap? { - val logoRes = when (CardPaymentSystem.resolvePaymentSystem(cardNumber)) { + val logoRes = when (CardPaymentSystem.resolve(cardNumber)) { MASTER_CARD -> R.drawable.acq_ic_master VISA -> R.drawable.acq_ic_visa_blue MIR -> R.drawable.acq_ic_mir diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/EditCard.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/EditCard.kt index 2a268b88..8c95caed 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/EditCard.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/EditCard.kt @@ -659,7 +659,7 @@ internal class EditCard @JvmOverloads constructor( override fun afterTextChanged(editable: Editable?) { when (editable) { is CardNumberEditable -> { - val paymentSystem = CardPaymentSystem.resolvePaymentSystem(cardNumber) + val paymentSystem = CardPaymentSystem.resolve(cardNumber) if (!paymentSystem.showLogo) { if (editable.isEmpty()) { post { hideLogoIfNeed() } @@ -927,7 +927,7 @@ internal class EditCard @JvmOverloads constructor( private fun isFilled(field: EditCardField): Boolean { return when (field) { CARD_NUMBER -> { - val paymentSystem = CardPaymentSystem.resolvePaymentSystem(cardNumber) + val paymentSystem = CardPaymentSystem.resolve(cardNumber) cardNumber.length in paymentSystem.range } EXPIRE_DATE -> cardDate.length == CardValidator.MAX_DATE_LENGTH @@ -1472,14 +1472,14 @@ internal class EditCard @JvmOverloads constructor( } private fun hideLogoIfNeed() { - val showLogo = CardPaymentSystem.resolvePaymentSystem(cardNumber).showLogo + val showLogo = CardPaymentSystem.resolve(cardNumber).showLogo if (!showLogo && checkFlags(FLAG_CARD_SYSTEM_LOGO) && viewState != CARD_LOGO_ANIMATION_STATE) { hideCardSystemLogo() } } private fun updateCardInputFilter() { - val paymentSystem = CardPaymentSystem.resolvePaymentSystem(cardNumber) + val paymentSystem = CardPaymentSystem.resolve(cardNumber) cardNumberEditable.filters = arrayOf(InputFilter.LengthFilter(paymentSystem.range.last)) } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/validators/CardValidator.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/validators/CardValidator.kt index e1a0cb62..6c11203f 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/validators/CardValidator.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/validators/CardValidator.kt @@ -34,7 +34,7 @@ internal object CardValidator { return false } - val cardType = CardPaymentSystem.resolvePaymentSystem(cardNumber) + val cardType = CardPaymentSystem.resolve(cardNumber) val allowedLengths = cardType.range var lengthAllowed = false diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/fragments/AttachCardFragment.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/fragments/AttachCardFragment.kt index 75da993a..315ffaa8 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/fragments/AttachCardFragment.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/fragments/AttachCardFragment.kt @@ -90,7 +90,6 @@ internal class AttachCardFragment : BaseAcquiringFragment() { private fun handleScreenState(screenState: ScreenState) { if (screenState is ErrorButtonClickedEvent) { - cardDataInput.clearInput() attachCardViewModel.showCardInput() } } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/BankIssuer.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/BankIssuer.kt index a97b2303..6feded7b 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/BankIssuer.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/BankIssuer.kt @@ -2,6 +2,7 @@ package ru.tinkoff.acquiring.sdk.utils import android.content.Context import ru.tinkoff.acquiring.sdk.R +import ru.tinkoff.acquiring.sdk.redesign.common.carddatainput.CardNumberFormatter enum class BankIssuer(val bins: Collection) { @@ -28,9 +29,10 @@ enum class BankIssuer(val bins: Collection) { companion object { - fun resolve(cardNumber: String): BankIssuer { - if (cardNumber.length < 6) return UNKNOWN - return values().find { bank -> bank.matches(cardNumber) } ?: OTHER + fun resolve(cardNumber: String?): BankIssuer { + val number = CardNumberFormatter.normalize(cardNumber) + if (number.length < 6) return UNKNOWN + return values().find { bank -> bank.matches(number) } ?: OTHER } } } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CardSystemIconsHolder.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CardSystemIconsHolder.kt index f201da43..4ac4a813 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CardSystemIconsHolder.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CardSystemIconsHolder.kt @@ -32,7 +32,7 @@ import ru.tinkoff.acquiring.sdk.ui.customview.editcard.EditCardSystemIconsHolder internal class CardSystemIconsHolder(private val context: Context) : EditCardSystemIconsHolder { override fun getCardSystemLogo(cardNumber: String): Bitmap? { - val logoRes = when (CardPaymentSystem.resolvePaymentSystem(cardNumber)) { + val logoRes = when (CardPaymentSystem.resolve(cardNumber)) { CardPaymentSystem.MASTER_CARD -> R.drawable.acq_ic_master CardPaymentSystem.VISA -> R.drawable.acq_ic_visa_blue CardPaymentSystem.MIR -> R.drawable.acq_ic_mir diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/CardLogoProvider.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/CardLogoProvider.kt new file mode 100644 index 00000000..7f284354 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/CardLogoProvider.kt @@ -0,0 +1,93 @@ +package ru.tinkoff.acquiring.sdk.viewmodel + +import ru.tinkoff.acquiring.sdk.R +import ru.tinkoff.acquiring.sdk.redesign.common.carddatainput.CardNumberFormatter +import ru.tinkoff.acquiring.sdk.ui.customview.editcard.CardPaymentSystem +import ru.tinkoff.acquiring.sdk.ui.customview.editcard.CardPaymentSystem.MAESTRO +import ru.tinkoff.acquiring.sdk.ui.customview.editcard.CardPaymentSystem.MASTER_CARD +import ru.tinkoff.acquiring.sdk.ui.customview.editcard.CardPaymentSystem.MIR +import ru.tinkoff.acquiring.sdk.ui.customview.editcard.CardPaymentSystem.UNION_PAY +import ru.tinkoff.acquiring.sdk.ui.customview.editcard.CardPaymentSystem.VISA +import ru.tinkoff.acquiring.sdk.utils.BankIssuer +import ru.tinkoff.acquiring.sdk.utils.BankIssuer.ALFABANK +import ru.tinkoff.acquiring.sdk.utils.BankIssuer.GAZPROMBANK +import ru.tinkoff.acquiring.sdk.utils.BankIssuer.OTHER +import ru.tinkoff.acquiring.sdk.utils.BankIssuer.RAIFFEISEN +import ru.tinkoff.acquiring.sdk.utils.BankIssuer.SBERBANK +import ru.tinkoff.acquiring.sdk.utils.BankIssuer.TINKOFF +import ru.tinkoff.acquiring.sdk.utils.BankIssuer.UNKNOWN +import ru.tinkoff.acquiring.sdk.utils.BankIssuer.VTB + +internal object CardLogoProvider { + + fun getCardLogo(cardNumber: String?): Int = with(CardNumberFormatter.normalize(cardNumber)) { + getCardLogo(CardPaymentSystem.resolve(this), BankIssuer.resolve(this)) + } + + fun getCardLogo(paymentSystem: CardPaymentSystem, bankIssuer: BankIssuer): Int = when (bankIssuer) { + SBERBANK -> when (paymentSystem) { + MIR -> R.drawable.acq_ic_card_mir_sberbank + MASTER_CARD -> R.drawable.acq_ic_card_mastercard_sberbank + VISA -> R.drawable.acq_ic_card_visa_sberbank + MAESTRO -> R.drawable.acq_ic_card_maestro_sberbank + UNION_PAY -> R.drawable.acq_ic_card_unionpay_sberbank + else -> R.drawable.acq_ic_card_unknown_unknown + } + VTB -> when (paymentSystem) { + MIR -> R.drawable.acq_ic_card_mir_vtb + MASTER_CARD -> R.drawable.acq_ic_card_mastercard_vtb + VISA -> R.drawable.acq_ic_card_visa_vtb + MAESTRO -> R.drawable.acq_ic_card_maestro_vtb + UNION_PAY -> R.drawable.acq_ic_card_unionpay_vtb + else -> R.drawable.acq_ic_card_unknown_unknown + } + ALFABANK -> when (paymentSystem) { + MIR -> R.drawable.acq_ic_card_mir_alfabank + MASTER_CARD -> R.drawable.acq_ic_card_mastercard_alfabank + VISA -> R.drawable.acq_ic_card_visa_alfabank + MAESTRO -> R.drawable.acq_ic_card_maestro_alfabank + UNION_PAY -> R.drawable.acq_ic_card_unionpay_alfabank + else -> R.drawable.acq_ic_card_unknown_unknown + } + TINKOFF -> when (paymentSystem) { + MIR -> R.drawable.acq_ic_card_mir_tinkoff + MASTER_CARD -> R.drawable.acq_ic_card_mastercard_tinkoff + VISA -> R.drawable.acq_ic_card_visa_tinkoff + MAESTRO -> R.drawable.acq_ic_card_maestro_tinkoff + UNION_PAY -> R.drawable.acq_ic_card_unionpay_tinkoff + else -> R.drawable.acq_ic_card_unknown_unknown + } + RAIFFEISEN -> when (paymentSystem) { + MIR -> R.drawable.acq_ic_card_mir_raiffeisen + MASTER_CARD -> R.drawable.acq_ic_card_mastercard_raiffeisen + VISA -> R.drawable.acq_ic_card_visa_raiffeisen + MAESTRO -> R.drawable.acq_ic_card_maestro_raiffeisen + UNION_PAY -> R.drawable.acq_ic_card_unionpay_raiffeisen + else -> R.drawable.acq_ic_card_unknown_unknown + } + GAZPROMBANK -> when (paymentSystem) { + MIR -> R.drawable.acq_ic_card_mir_gazprombank + MASTER_CARD -> R.drawable.acq_ic_card_mastercard_gazprombank + VISA -> R.drawable.acq_ic_card_visa_gazprombank + MAESTRO -> R.drawable.acq_ic_card_maestro_gazprombank + UNION_PAY -> R.drawable.acq_ic_card_unionpay_gazprombank + else -> R.drawable.acq_ic_card_unknown_unknown + } + OTHER -> when (paymentSystem) { + MIR -> R.drawable.acq_ic_card_mir_other + MASTER_CARD -> R.drawable.acq_ic_card_mastercard_other + VISA -> R.drawable.acq_ic_card_visa_other + MAESTRO -> R.drawable.acq_ic_card_maestro_other + UNION_PAY -> R.drawable.acq_ic_card_unionpay_other + else -> R.drawable.acq_ic_card_unknown_unknown + } + UNKNOWN -> when (paymentSystem) { + MIR -> R.drawable.acq_ic_card_mir_unknown + MASTER_CARD -> R.drawable.acq_ic_card_mastercard_unknown + VISA -> R.drawable.acq_ic_card_visa_unknown + MAESTRO -> R.drawable.acq_ic_card_maestro_unknown + UNION_PAY -> R.drawable.acq_ic_card_unionpay_unknown + else -> R.drawable.acq_ic_card_unknown_unknown + } + } +} \ No newline at end of file diff --git a/ui/src/main/res/drawable-night/acq_ic_card_maestro_unknown.xml b/ui/src/main/res/drawable-night/acq_ic_card_maestro_unknown.xml new file mode 100644 index 00000000..c0755c8c --- /dev/null +++ b/ui/src/main/res/drawable-night/acq_ic_card_maestro_unknown.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/ui/src/main/res/drawable-night/acq_ic_card_mastercard_unknown.xml b/ui/src/main/res/drawable-night/acq_ic_card_mastercard_unknown.xml new file mode 100644 index 00000000..e0eec07f --- /dev/null +++ b/ui/src/main/res/drawable-night/acq_ic_card_mastercard_unknown.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/ui/src/main/res/drawable-night/acq_ic_card_mir_unknown.xml b/ui/src/main/res/drawable-night/acq_ic_card_mir_unknown.xml new file mode 100644 index 00000000..c1890e9b --- /dev/null +++ b/ui/src/main/res/drawable-night/acq_ic_card_mir_unknown.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/ui/src/main/res/drawable-night/acq_ic_card_unionpay_unknown.xml b/ui/src/main/res/drawable-night/acq_ic_card_unionpay_unknown.xml new file mode 100644 index 00000000..ff9deac7 --- /dev/null +++ b/ui/src/main/res/drawable-night/acq_ic_card_unionpay_unknown.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/ui/src/main/res/drawable-night/acq_ic_card_unknown_unknown.xml b/ui/src/main/res/drawable-night/acq_ic_card_unknown_unknown.xml new file mode 100644 index 00000000..512bcbb0 --- /dev/null +++ b/ui/src/main/res/drawable-night/acq_ic_card_unknown_unknown.xml @@ -0,0 +1,15 @@ + + + + diff --git a/ui/src/main/res/drawable-night/acq_ic_card_visa_unknown.xml b/ui/src/main/res/drawable-night/acq_ic_card_visa_unknown.xml new file mode 100644 index 00000000..76ccbe0f --- /dev/null +++ b/ui/src/main/res/drawable-night/acq_ic_card_visa_unknown.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_maestro_alfabank.xml b/ui/src/main/res/drawable/acq_ic_card_maestro_alfabank.xml new file mode 100644 index 00000000..d03ddb07 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_maestro_alfabank.xml @@ -0,0 +1,25 @@ + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_maestro_gazprombank.xml b/ui/src/main/res/drawable/acq_ic_card_maestro_gazprombank.xml new file mode 100644 index 00000000..377e7b80 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_maestro_gazprombank.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_maestro_other.xml b/ui/src/main/res/drawable/acq_ic_card_maestro_other.xml new file mode 100644 index 00000000..368806dc --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_maestro_other.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_maestro_raiffeisen.xml b/ui/src/main/res/drawable/acq_ic_card_maestro_raiffeisen.xml new file mode 100644 index 00000000..bd4d036d --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_maestro_raiffeisen.xml @@ -0,0 +1,26 @@ + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_maestro_sberbank.xml b/ui/src/main/res/drawable/acq_ic_card_maestro_sberbank.xml new file mode 100644 index 00000000..7f20bd8a --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_maestro_sberbank.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_maestro_tinkoff.xml b/ui/src/main/res/drawable/acq_ic_card_maestro_tinkoff.xml new file mode 100644 index 00000000..42a32cc9 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_maestro_tinkoff.xml @@ -0,0 +1,26 @@ + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_maestro_unknown.xml b/ui/src/main/res/drawable/acq_ic_card_maestro_unknown.xml new file mode 100644 index 00000000..a8fb2f81 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_maestro_unknown.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_maestro_vtb.xml b/ui/src/main/res/drawable/acq_ic_card_maestro_vtb.xml new file mode 100644 index 00000000..fdc7b9c7 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_maestro_vtb.xml @@ -0,0 +1,26 @@ + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_mastercard_alfabank.xml b/ui/src/main/res/drawable/acq_ic_card_mastercard_alfabank.xml new file mode 100644 index 00000000..30a026d3 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_mastercard_alfabank.xml @@ -0,0 +1,25 @@ + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_mastercard_gazprombank.xml b/ui/src/main/res/drawable/acq_ic_card_mastercard_gazprombank.xml new file mode 100644 index 00000000..aa594392 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_mastercard_gazprombank.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_mastercard_other.xml b/ui/src/main/res/drawable/acq_ic_card_mastercard_other.xml new file mode 100644 index 00000000..3e918194 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_mastercard_other.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_mastercard_raiffeisen.xml b/ui/src/main/res/drawable/acq_ic_card_mastercard_raiffeisen.xml new file mode 100644 index 00000000..6b99981e --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_mastercard_raiffeisen.xml @@ -0,0 +1,26 @@ + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_mastercard_sberbank.xml b/ui/src/main/res/drawable/acq_ic_card_mastercard_sberbank.xml new file mode 100644 index 00000000..95b60ff7 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_mastercard_sberbank.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_mastercard_tinkoff.xml b/ui/src/main/res/drawable/acq_ic_card_mastercard_tinkoff.xml new file mode 100644 index 00000000..7e4df2c7 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_mastercard_tinkoff.xml @@ -0,0 +1,26 @@ + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_mastercard_unknown.xml b/ui/src/main/res/drawable/acq_ic_card_mastercard_unknown.xml new file mode 100644 index 00000000..9577c251 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_mastercard_unknown.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_mastercard_vtb.xml b/ui/src/main/res/drawable/acq_ic_card_mastercard_vtb.xml new file mode 100644 index 00000000..6d96a4d4 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_mastercard_vtb.xml @@ -0,0 +1,26 @@ + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_mir_alfabank.xml b/ui/src/main/res/drawable/acq_ic_card_mir_alfabank.xml new file mode 100644 index 00000000..8daa7040 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_mir_alfabank.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_mir_gazprombank.xml b/ui/src/main/res/drawable/acq_ic_card_mir_gazprombank.xml new file mode 100644 index 00000000..dda7a48f --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_mir_gazprombank.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_mir_other.xml b/ui/src/main/res/drawable/acq_ic_card_mir_other.xml new file mode 100644 index 00000000..c7ace1de --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_mir_other.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_mir_raiffeisen.xml b/ui/src/main/res/drawable/acq_ic_card_mir_raiffeisen.xml new file mode 100644 index 00000000..3cf752be --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_mir_raiffeisen.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_mir_sberbank.xml b/ui/src/main/res/drawable/acq_ic_card_mir_sberbank.xml new file mode 100644 index 00000000..daf7bf9d --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_mir_sberbank.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_mir_tinkoff.xml b/ui/src/main/res/drawable/acq_ic_card_mir_tinkoff.xml new file mode 100644 index 00000000..d67c7db9 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_mir_tinkoff.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_mir_unknown.xml b/ui/src/main/res/drawable/acq_ic_card_mir_unknown.xml new file mode 100644 index 00000000..4feb135d --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_mir_unknown.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_mir_vtb.xml b/ui/src/main/res/drawable/acq_ic_card_mir_vtb.xml new file mode 100644 index 00000000..ac6da991 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_mir_vtb.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_unionpay_alfabank.xml b/ui/src/main/res/drawable/acq_ic_card_unionpay_alfabank.xml new file mode 100644 index 00000000..77425160 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_unionpay_alfabank.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_unionpay_gazprombank.xml b/ui/src/main/res/drawable/acq_ic_card_unionpay_gazprombank.xml new file mode 100644 index 00000000..c9addc68 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_unionpay_gazprombank.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_unionpay_other.xml b/ui/src/main/res/drawable/acq_ic_card_unionpay_other.xml new file mode 100644 index 00000000..34698417 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_unionpay_other.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_unionpay_raiffeisen.xml b/ui/src/main/res/drawable/acq_ic_card_unionpay_raiffeisen.xml new file mode 100644 index 00000000..c5a79e73 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_unionpay_raiffeisen.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_unionpay_sberbank.xml b/ui/src/main/res/drawable/acq_ic_card_unionpay_sberbank.xml new file mode 100644 index 00000000..d7e1b6db --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_unionpay_sberbank.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_unionpay_tinkoff.xml b/ui/src/main/res/drawable/acq_ic_card_unionpay_tinkoff.xml new file mode 100644 index 00000000..c80f95d1 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_unionpay_tinkoff.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_unionpay_unknown.xml b/ui/src/main/res/drawable/acq_ic_card_unionpay_unknown.xml new file mode 100644 index 00000000..34e77c55 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_unionpay_unknown.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_unionpay_vtb.xml b/ui/src/main/res/drawable/acq_ic_card_unionpay_vtb.xml new file mode 100644 index 00000000..32e82f05 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_unionpay_vtb.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_unknown_unknown.xml b/ui/src/main/res/drawable/acq_ic_card_unknown_unknown.xml new file mode 100644 index 00000000..3588724f --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_unknown_unknown.xml @@ -0,0 +1,15 @@ + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_visa_alfabank.xml b/ui/src/main/res/drawable/acq_ic_card_visa_alfabank.xml new file mode 100644 index 00000000..efc24b57 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_visa_alfabank.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_visa_gazprombank.xml b/ui/src/main/res/drawable/acq_ic_card_visa_gazprombank.xml new file mode 100644 index 00000000..e03c205e --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_visa_gazprombank.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_visa_other.xml b/ui/src/main/res/drawable/acq_ic_card_visa_other.xml new file mode 100644 index 00000000..a326f911 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_visa_other.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_visa_raiffeisen.xml b/ui/src/main/res/drawable/acq_ic_card_visa_raiffeisen.xml new file mode 100644 index 00000000..6129ce30 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_visa_raiffeisen.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_visa_sberbank.xml b/ui/src/main/res/drawable/acq_ic_card_visa_sberbank.xml new file mode 100644 index 00000000..d1da3c00 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_visa_sberbank.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_visa_tinkoff.xml b/ui/src/main/res/drawable/acq_ic_card_visa_tinkoff.xml new file mode 100644 index 00000000..d1604170 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_visa_tinkoff.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_visa_unknown.xml b/ui/src/main/res/drawable/acq_ic_card_visa_unknown.xml new file mode 100644 index 00000000..8b805b1a --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_visa_unknown.xml @@ -0,0 +1,15 @@ + + + + diff --git a/ui/src/main/res/drawable/acq_ic_card_visa_vtb.xml b/ui/src/main/res/drawable/acq_ic_card_visa_vtb.xml new file mode 100644 index 00000000..3bae2e81 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_visa_vtb.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/ui/src/main/res/layout/acq_card_list_item.xml b/ui/src/main/res/layout/acq_card_list_item.xml index 961ff637..cfb70c25 100644 --- a/ui/src/main/res/layout/acq_card_list_item.xml +++ b/ui/src/main/res/layout/acq_card_list_item.xml @@ -19,11 +19,13 @@ android:layout_height="wrap_content" android:orientation="horizontal"> - + android:layout_marginStart="16dp" + tools:src="@drawable/acq_ic_card_mir_tinkoff"/> + + + \ No newline at end of file diff --git a/ui/src/test/java/CardPaymentSystemTests.kt b/ui/src/test/java/CardPaymentSystemTests.kt index 47593e9a..a05b7908 100644 --- a/ui/src/test/java/CardPaymentSystemTests.kt +++ b/ui/src/test/java/CardPaymentSystemTests.kt @@ -11,7 +11,7 @@ class CardPaymentSystemTests { @Test fun verifyPaymentSystems() { cardNumbers.forEach { (number, paymentSystem) -> - assert(CardPaymentSystem.resolvePaymentSystem(number) == paymentSystem) + assert(CardPaymentSystem.resolve(number) == paymentSystem) } } From 894878dd0f8d1b85392936fdef339668b42e328b Mon Sep 17 00:00:00 2001 From: jqwout Date: Fri, 18 Nov 2022 14:36:38 +0300 Subject: [PATCH 017/126] =?UTF-8?q?MC-7035=20=D0=B0=D0=B1=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=BA=D1=86=D0=B8=D1=8F=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=B2=D1=8B=D1=87=D0=B8=D1=81=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B8=D0=BC=D0=B5=D0=BD=D0=B8=20=D0=B1=D0=B0=D0=BD=D0=BA=D0=B0?= =?UTF-8?q?,=20=D0=BE=D0=B1=D0=BB=D0=B5=D0=B3=D1=87=D0=B8=D1=82=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cards/list/adapters/CardsListAdapter.kt | 2 +- .../cards/list/models/CardItemUiModel.kt | 9 +++------ .../list/presentation/CardsListViewModel.kt | 17 ++++++++++++++--- .../tinkoff/acquiring/sdk/utils/BankIssuer.kt | 8 ++++++++ .../sdk/viewmodel/ViewModelProviderFactory.kt | 7 ++++++- .../cards/list/CardsBaseViewModelTest.kt | 6 +++++- .../cards/list/CardsDeleteViewModelTest.kt | 2 ++ 7 files changed, 39 insertions(+), 12 deletions(-) diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt index 497c3aeb..126277bf 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt @@ -84,7 +84,7 @@ class CardsListAdapter( cardLogo.setImageResource(CardLogoProvider.getCardLogo(card.pan)) cardNameView.text = itemView.context.getString( R.string.acq_cardlist_bankname, - card.bankName(itemView.context).orEmpty(), + card.bankName, card.tail ) bindDeleteVisibility(card.showDelete) diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/models/CardItemUiModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/models/CardItemUiModel.kt index eb08bb49..aae75b91 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/models/CardItemUiModel.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/models/CardItemUiModel.kt @@ -1,22 +1,19 @@ package ru.tinkoff.acquiring.sdk.redesign.cards.list.models -import android.content.Context import ru.tinkoff.acquiring.sdk.models.Card -import ru.tinkoff.acquiring.sdk.utils.BankIssuer data class CardItemUiModel( private val card: Card, - // TODO after delete card task val showDelete: Boolean = false, - val isBlocked: Boolean = false + val isBlocked: Boolean = false, + + val bankName: String? ) { val id = card.cardId val pan: String? = card.pan val tail = card.pan?.takeLast(4) - - fun bankName(context: Context) = BankIssuer.resolve(pan).getCaption(context) } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt index b59e25bc..9929c490 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt @@ -13,6 +13,7 @@ import ru.tinkoff.acquiring.sdk.redesign.cards.list.ui.CardListEvent import ru.tinkoff.acquiring.sdk.redesign.cards.list.ui.CardListMode import ru.tinkoff.acquiring.sdk.redesign.cards.list.ui.CardsListState import ru.tinkoff.acquiring.sdk.responses.GetCardListResponse +import ru.tinkoff.acquiring.sdk.utils.BankCaptionProvider import ru.tinkoff.acquiring.sdk.utils.ConnectionChecker import ru.tinkoff.acquiring.sdk.utils.CoroutineManager @@ -22,6 +23,7 @@ import ru.tinkoff.acquiring.sdk.utils.CoroutineManager internal class CardsListViewModel( private val sdk: AcquiringSdk, private val connectionChecker: ConnectionChecker, + private val bankCaptionProvider: BankCaptionProvider, private val manager: CoroutineManager = CoroutineManager() ) : ViewModel() { @@ -84,7 +86,8 @@ internal class CardsListViewModel( deleteJob?.cancel() }, onFailure = { - val list = checkNotNull((stateFlow.value as? CardsListState.Content)?.cards) + val list = + checkNotNull((stateFlow.value as? CardsListState.Content)?.cards) stateFlow.update { CardsListState.Content(it.mode, true, list) } eventFlow.value = CardListEvent.ShowError deleteJob?.cancel() @@ -97,7 +100,12 @@ internal class CardsListViewModel( fun changeMode(mode: CardListMode) { stateFlow.update { state -> val prev = state as CardsListState.Content - val cards = prev.cards.map { it.copy(showDelete = mode == CardListMode.DELETE, isBlocked = it.isBlocked) } + val cards = prev.cards.map { + it.copy( + showDelete = mode == CardListMode.DELETE, + isBlocked = it.isBlocked + ) + } CardsListState.Content(mode, false, cards) } } @@ -124,7 +132,10 @@ internal class CardsListViewModel( activeCards = activeCards.filter { card -> !card.rebillId.isNullOrBlank() } } - return activeCards.map(::CardItemUiModel) + return activeCards.map { + val cardNumber = checkNotNull(it.pan) + CardItemUiModel(card = it, bankName = bankCaptionProvider(cardNumber)) + } } private fun handleGetCardListError(it: Exception) { diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/BankIssuer.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/BankIssuer.kt index 6feded7b..4ac30481 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/BankIssuer.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/BankIssuer.kt @@ -4,6 +4,14 @@ import android.content.Context import ru.tinkoff.acquiring.sdk.R import ru.tinkoff.acquiring.sdk.redesign.common.carddatainput.CardNumberFormatter +fun interface BankCaptionProvider { + operator fun invoke(pan: String): String? +} + +internal class BankCaptionResourceProvider(private val context: Context) : BankCaptionProvider { + override fun invoke(pan: String) = BankIssuer.resolve(pan).getCaption(context) +} + enum class BankIssuer(val bins: Collection) { SBERBANK(SBERBANK_BINS), diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/ViewModelProviderFactory.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/ViewModelProviderFactory.kt index ec06bb84..0943e014 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/ViewModelProviderFactory.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/ViewModelProviderFactory.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import ru.tinkoff.acquiring.sdk.AcquiringSdk import ru.tinkoff.acquiring.sdk.redesign.cards.list.presentation.CardsListViewModel +import ru.tinkoff.acquiring.sdk.utils.BankCaptionResourceProvider import ru.tinkoff.acquiring.sdk.utils.ConnectionChecker /** @@ -48,7 +49,11 @@ internal class ViewModelProviderFactory( ) private val redesignViewModels = mapOf, ViewModel>( - CardsListViewModel::class.java to CardsListViewModel(sdk, ConnectionChecker(application)) + CardsListViewModel::class.java to CardsListViewModel( + sdk, + ConnectionChecker(application), + BankCaptionResourceProvider(application) + ) ) @Suppress("UNCHECKED_CAST") diff --git a/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsBaseViewModelTest.kt b/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsBaseViewModelTest.kt index ad093f0a..b3872620 100644 --- a/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsBaseViewModelTest.kt +++ b/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsBaseViewModelTest.kt @@ -14,6 +14,7 @@ import ru.tinkoff.acquiring.sdk.redesign.cards.list.presentation.CardsListViewMo import ru.tinkoff.acquiring.sdk.redesign.cards.list.ui.CardsListState import ru.tinkoff.acquiring.sdk.requests.GetCardListRequest import ru.tinkoff.acquiring.sdk.responses.GetCardListResponse +import ru.tinkoff.acquiring.sdk.utils.BankCaptionProvider import ru.tinkoff.acquiring.sdk.utils.ConnectionChecker import ru.tinkoff.acquiring.sdk.utils.RequestResult import turbineDelay @@ -138,7 +139,10 @@ internal class CardsListViewModelTest { val sdk = mock { on { getCardList(any()) } doReturn request } - return CardsListViewModel(sdk, connectionChecker) + val provider = BankCaptionProvider { + "Tinkoff" + } + return CardsListViewModel(sdk, connectionChecker, provider) } } \ No newline at end of file diff --git a/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsDeleteViewModelTest.kt b/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsDeleteViewModelTest.kt index 25d2f6a3..d41c700d 100644 --- a/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsDeleteViewModelTest.kt +++ b/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsDeleteViewModelTest.kt @@ -22,6 +22,7 @@ import ru.tinkoff.acquiring.sdk.redesign.cards.list.ui.CardListMode import ru.tinkoff.acquiring.sdk.redesign.cards.list.ui.CardsListState import ru.tinkoff.acquiring.sdk.requests.RemoveCardRequest import ru.tinkoff.acquiring.sdk.responses.RemoveCardResponse +import ru.tinkoff.acquiring.sdk.utils.BankCaptionProvider import ru.tinkoff.acquiring.sdk.utils.ConnectionChecker import ru.tinkoff.acquiring.sdk.utils.CoroutineManager import ru.tinkoff.acquiring.sdk.utils.RequestResult @@ -144,6 +145,7 @@ internal class CardsDeleteViewModelTest { val vm = CardsListViewModel( asdk, connectionMock, + BankCaptionProvider { "Tinkoff" }, CoroutineManager(dispatcher, dispatcher) ).apply { stateFlow.value = initState From 82326ddcefc627e636425a5f80de11beea4674b6 Mon Sep 17 00:00:00 2001 From: jqwout Date: Thu, 17 Nov 2022 17:24:46 +0300 Subject: [PATCH 018/126] =?UTF-8?q?MC-7343=20-=20=D0=98=D0=BD=D1=84=D0=BE?= =?UTF-8?q?=20=D0=BE=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B0=D1=85=20=D0=B2=20=D0=B0?= =?UTF-8?q?=D0=BF=D0=BF=D1=86=D0=B5=D0=BD=D1=82=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/app_center.yml | 19 ++++++++++++++++++- .../app_center_release_notes_test.yml | 17 ----------------- .github/workflows/merge_request.yml | 13 +------------ build.gradle | 10 +++++----- 4 files changed, 24 insertions(+), 35 deletions(-) delete mode 100644 .github/workflows/app_center_release_notes_test.yml diff --git a/.github/workflows/app_center.yml b/.github/workflows/app_center.yml index 09c9364e..60276a97 100644 --- a/.github/workflows/app_center.yml +++ b/.github/workflows/app_center.yml @@ -2,6 +2,11 @@ name: publish demo to AppCenter on: workflow_dispatch: + inputs: + diff_with_origin: + description: 'diff with origin' + required: true + default: 'origin/master' jobs: build: @@ -16,6 +21,12 @@ jobs: java-version: '11' - name: build release run: ./gradlew :sample:assembleDebug + - name: get the realease Notes + run: | + NEW_CHANGES=$(./gradlew -q :realeaseNotes -PdiffWithOrigin='${{ github.event.inputs.diff_with_origin }}') + echo "NEW_CHANGES<> $GITHUB_ENV + echo "$NEW_CHANGES" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV - name: upload artefact to App Center uses: wzieba/AppCenter-Github-Action@v1 with: @@ -24,4 +35,10 @@ jobs: group: Collaborators file: sample/build/outputs/apk/debug/sample-debug.apk notifyTesters: true - debug: false \ No newline at end of file + debug: false + releaseNotes: |+ + Branch name : ${{ github.head_ref || github.ref_name }} + + Last changes : + + ${{ env.NEW_CHANGES }} \ No newline at end of file diff --git a/.github/workflows/app_center_release_notes_test.yml b/.github/workflows/app_center_release_notes_test.yml deleted file mode 100644 index c69838e3..00000000 --- a/.github/workflows/app_center_release_notes_test.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: publish demo to AppCenter - -on: - workflow_dispatch: - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - name: Set the value - id: step_one - run: | - echo 'log<> $GITHUB_ENV - ./gradlew -q :diffWithMaster >> $GITHUB_ENV - echo 'EOF' >> $GITHUB_ENV \ No newline at end of file diff --git a/.github/workflows/merge_request.yml b/.github/workflows/merge_request.yml index e52bd247..b1ad6cf5 100644 --- a/.github/workflows/merge_request.yml +++ b/.github/workflows/merge_request.yml @@ -5,18 +5,7 @@ on: branches: - 'master' - 'v**' - paths: - - ".github/workflows/app_center_release_notes_test.yaml" jobs: check: - uses: tinkoff-mobile-tech/workflows/.github/workflows/android_lib.merge_request.yml@v1 - - log: - steps: - - name: Set the value - id: step_one - run: | - echo 'log<> $GITHUB_ENV - ./gradlew -q :diffWithMaster >> $GITHUB_ENV - echo 'EOF' >> $GITHUB_ENV \ No newline at end of file + uses: tinkoff-mobile-tech/workflows/.github/workflows/android_lib.merge_request.yml@v1 \ No newline at end of file diff --git a/build.gradle b/build.gradle index 4de21b55..171afd2c 100644 --- a/build.gradle +++ b/build.gradle @@ -34,14 +34,14 @@ allprojects { } } -tasks.register("diffWithMaster") { +tasks.register("realeaseNotes") { doLast { - def branch = grgit.branch.current().getName() + def branch = grgit.branch.current().fullName def log = grgit - .log { range branch, 'master' } - .findAll { !it.shortMessage.startsWith("Merge pull request ") } + .log { range branch, diffWithOrigin } + .findAll { !it.shortMessage.startsWith("Merge") } .collect { it.shortMessage } - .join("\n") + .join("\n\n") print log } } \ No newline at end of file From 69d0c05b6bd4f112157ba78255d4b5428b007fd8 Mon Sep 17 00:00:00 2001 From: "i.khafizov" Date: Fri, 25 Nov 2022 19:22:49 +0400 Subject: [PATCH 019/126] MC-7274 card list screen improvements --- .../acquiring/sample/ui/MainActivity.kt | 39 +++--- ui/src/main/AndroidManifest.xml | 2 +- .../tinkoff/acquiring/sdk/TinkoffAcquiring.kt | 75 +++++++++--- .../options/screen/SavedCardsOptions.kt | 6 +- .../cards/list/adapters/CardsListAdapter.kt | 6 +- .../list/presentation/CardsListViewModel.kt | 17 ++- .../cards/list/ui/CardsListActivity.kt | 111 +++++++++++++----- .../redesign/cards/list/ui/CardsListState.kt | 8 +- .../carddatainput/CardDataInputFragment.kt | 21 ++++ .../sdk/ui/activities/AttachCardActivity.kt | 19 ++- .../ui/activities/BaseAcquiringActivity.kt | 16 +-- .../sdk/ui/customview/LoaderButton.kt | 4 +- .../sdk/ui/fragments/AttachCardFragment.kt | 7 +- .../acquiring/sdk/utils/AcqSnackBarHelper.kt | 55 +++++++-- .../acquiring/sdk/utils/ErrorResolver.kt | 18 +++ .../sdk/viewmodel/AttachCardViewModel.kt | 5 +- .../res/color/acq_button_text_selector.xml | 7 ++ .../res/drawable/acq_button_yellow_bg.xml | 14 +-- .../drawable/acq_button_yellow_bg_ripple.xml | 27 +++++ .../main/res/drawable/acq_ic_card_sparkle.xml | 31 +++++ .../res/drawable/acq_ic_snackbar_icon_bg.xml | 10 ++ .../acq_selectable_item_background_circle.xml | 11 ++ .../main/res/layout/acq_card_list_content.xml | 1 + ui/src/main/res/layout/acq_card_list_item.xml | 5 +- .../res/layout/acq_fragment_attach_card.xml | 5 +- ...t.xml => acq_snackbar_progress_layout.xml} | 0 ui/src/main/res/layout/acq_snackbar_view.xml | 6 - .../layout/acq_snackbar_with_icon_layout.xml | 36 ++++++ ui/src/main/res/values-night/colors.xml | 2 + ui/src/main/res/values-ru/strings.xml | 6 +- ui/src/main/res/values/colors.xml | 6 +- ui/src/main/res/values/strings.xml | 8 +- .../cards/list/CardsDeleteViewModelTest.kt | 9 +- 33 files changed, 450 insertions(+), 143 deletions(-) create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/ErrorResolver.kt create mode 100644 ui/src/main/res/color/acq_button_text_selector.xml create mode 100644 ui/src/main/res/drawable/acq_button_yellow_bg_ripple.xml create mode 100644 ui/src/main/res/drawable/acq_ic_card_sparkle.xml create mode 100644 ui/src/main/res/drawable/acq_ic_snackbar_icon_bg.xml create mode 100644 ui/src/main/res/drawable/acq_selectable_item_background_circle.xml rename ui/src/main/res/layout/{acq_snackbar_layout.xml => acq_snackbar_progress_layout.xml} (100%) delete mode 100644 ui/src/main/res/layout/acq_snackbar_view.xml create mode 100644 ui/src/main/res/layout/acq_snackbar_with_icon_layout.xml diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt index d4f44ea0..1cd4cafa 100644 --- a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt @@ -37,13 +37,12 @@ import ru.tinkoff.acquiring.sample.service.PriceNotificationReceiver import ru.tinkoff.acquiring.sample.utils.PaymentNotificationManager import ru.tinkoff.acquiring.sample.utils.SettingsSdkManager import ru.tinkoff.acquiring.sample.utils.TerminalsManager -import ru.tinkoff.acquiring.sdk.TinkoffAcquiring -import ru.tinkoff.acquiring.sdk.TinkoffAcquiring.Companion.EXTRA_CARD_ID +import ru.tinkoff.acquiring.sdk.TinkoffAcquiring.AttachCard import ru.tinkoff.acquiring.sdk.TinkoffAcquiring.Companion.RESULT_ERROR +import ru.tinkoff.acquiring.sdk.TinkoffAcquiring.SavedCards import ru.tinkoff.acquiring.sdk.localization.AsdkSource import ru.tinkoff.acquiring.sdk.localization.Language import ru.tinkoff.acquiring.sdk.models.options.FeaturesOptions -import ru.tinkoff.acquiring.sdk.models.options.screen.SavedCardsOptions import ru.tinkoff.acquiring.sdk.models.result.CardResult import ru.tinkoff.acquiring.sdk.threeds.ThreeDsHelper @@ -58,8 +57,20 @@ class MainActivity : AppCompatActivity(), BooksListAdapter.BookDetailsClickListe private val priceNotificationReceiver = PriceNotificationReceiver() private var selectedCardIdForDemo: String? = null - private val attachCard = registerForActivityResult(TinkoffAcquiring.AttachCardContract) { cardId -> - cardId?.let { PaymentResultActivity.start(this, it) } ?: toast(R.string.attachment_failed) + private val attachCard = registerForActivityResult(AttachCard.Contract) { result -> + when (result) { + is AttachCard.Success -> PaymentResultActivity.start(this, result.cardId) + is AttachCard.Error -> toast(result.error.message ?: getString(R.string.attachment_failed)) + is AttachCard.Canceled -> toast(R.string.attachment_cancelled) + } + } + + private val savedCards = registerForActivityResult(SavedCards.Contract) { result -> + when (result) { + is SavedCards.Success -> selectedCardIdForDemo = result.selectedCardId + is SavedCards.Error -> toast(result.error.message ?: getString(R.string.error_title)) + else -> Unit + } } override fun onCreate(savedInstanceState: Bundle?) { @@ -152,15 +163,6 @@ class MainActivity : AppCompatActivity(), BooksListAdapter.BookDetailsClickListe Toast.makeText(this, R.string.payment_failed, Toast.LENGTH_SHORT).show() } } - SAVED_CARDS_REQUEST_CODE -> { - val selectedCardId = data?.getStringExtra(EXTRA_CARD_ID) - selectedCardIdForDemo = selectedCardId - - when (resultCode) { - RESULT_OK -> Toast.makeText(this, "Выбранная карта: CardId=$selectedCardId", Toast.LENGTH_SHORT).show() - RESULT_ERROR -> Toast.makeText(this, R.string.error_title, Toast.LENGTH_SHORT).show() - } - } NOTIFICATION_PAYMENT_REQUEST_CODE -> { when (resultCode) { RESULT_OK -> { @@ -239,7 +241,7 @@ class MainActivity : AppCompatActivity(), BooksListAdapter.BookDetailsClickListe val settings = SettingsSdkManager(this) val params = TerminalsManager.selectedTerminal - val options = SavedCardsOptions().setOptions { + savedCards.launch(SampleApplication.tinkoffAcquiring.savedCardsOptions { customerOptions { customerKey = params.customerKey checkType = settings.checkType @@ -254,17 +256,12 @@ class MainActivity : AppCompatActivity(), BooksListAdapter.BookDetailsClickListe userCanSelectCard = true selectedCardId = selectedCardIdForDemo } - } - - SampleApplication.tinkoffAcquiring.openSavedCardsScreen(this, - options, - SAVED_CARDS_REQUEST_CODE) + }) } companion object { private const val STATIC_QR_REQUEST_CODE = 12 - private const val SAVED_CARDS_REQUEST_CODE = 13 const val NOTIFICATION_PAYMENT_REQUEST_CODE = 14 const val THREE_DS_REQUEST_CODE = 15 diff --git a/ui/src/main/AndroidManifest.xml b/ui/src/main/AndroidManifest.xml index 3207b0b8..db866913 100644 --- a/ui/src/main/AndroidManifest.xml +++ b/ui/src/main/AndroidManifest.xml @@ -49,7 +49,7 @@ android:name=".ui.activities.AttachCardActivity" android:screenOrientation="unspecified" android:theme="@style/AcquiringTheme" - android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustResize" /> }.launch(options)", + @Deprecated("registerForActivityResult(AttachCard.Contract) { result -> }.launch(options)", ReplaceWith("registerForActivityResult(AttachCardContract) { cardId ->\n" + " // handle result\n" + "}.launch(attachCardOptions {\n" + @@ -242,6 +242,7 @@ class TinkoffAcquiring( * @param options настройки привязки карты и визуального отображения экрана * @param requestCode код для получения результата, по завершению работы экрана Acquiring SDK */ + @Deprecated("registerForActivityResult(AttachCard.Contract) { result -> }.launch(options)") fun openAttachCardScreen(fragment: Fragment, options: AttachCardOptions, requestCode: Int) { val intent = prepareIntent(fragment.requireContext(), options, AttachCardActivity::class.java) fragment.startActivityForResult(intent, requestCode) @@ -258,6 +259,12 @@ class TinkoffAcquiring( * В случае выбора покупателем приоритетной карты, возвращается intent * с параметром String по ключу [TinkoffAcquiring.EXTRA_CARD_ID] */ + @Deprecated("registerForActivityResult(SavedCards.Contract) { result -> }.launch(options)", + ReplaceWith("registerForActivityResult(SavedCardsContract) { result ->\n" + + " // handle result\n" + + "}.launch(savedCardsOptions {\n" + + " //setup options\n" + + "})")) fun openSavedCardsScreen(activity: Activity, savedCardsOptions: SavedCardsOptions, requestCode: Int) { val intent = prepareIntent(activity, savedCardsOptions, CardsListActivity::class.java) activity.startActivityForResult(intent, requestCode) @@ -274,6 +281,7 @@ class TinkoffAcquiring( * В случае выбора покупателем приоритетной карты, возвращается intent * с параметром String по ключу [TinkoffAcquiring.EXTRA_CARD_ID] */ + @Deprecated("registerForActivityResult(SavedCards.Contract) { result -> }.launch(options)") fun openSavedCardsScreen(fragment: Fragment, savedCardsOptions: SavedCardsOptions, requestCode: Int) { val intent = prepareIntent(fragment.requireContext(), savedCardsOptions, CardsListActivity::class.java) fragment.startActivityForResult(intent, requestCode) @@ -411,11 +419,11 @@ class TinkoffAcquiring( notificationId: Int? = null): PendingIntent { options.setTerminalParams(terminalKey, publicKey) return NotificationPaymentActivity.createPendingIntent(activity, - options, - requestCode, - NotificationPaymentActivity.PaymentMethod.GPAY, - notificationId, - googlePayParams) + options, + requestCode, + NotificationPaymentActivity.PaymentMethod.GPAY, + notificationId, + googlePayParams) } /** @@ -451,16 +459,55 @@ class TinkoffAcquiring( setup(options) } - object AttachCardContract : ActivityResultContract() { + fun savedCardsOptions(setup: SavedCardsOptions.() -> Unit) = SavedCardsOptions().also { options -> + options.setTerminalParams(terminalKey, publicKey) + setup(options) + } + + object AttachCard { + + sealed class Result + class Success(val cardId: String) : Result() + class Canceled : Result() + class Error(val error: Throwable) : Result() + + + object Contract : ActivityResultContract() { + + override fun createIntent(context: Context, input: AttachCardOptions): Intent = + BaseAcquiringActivity.createIntent(context, input.apply { + setTerminalParams(terminalKey, publicKey) + }, AttachCardActivity::class.java) + + override fun parseResult(resultCode: Int, intent: Intent?): Result = when (resultCode) { + AppCompatActivity.RESULT_OK -> Success(intent!!.getStringExtra(EXTRA_CARD_ID)!!) + RESULT_ERROR -> Error(intent!!.getSerializableExtra(EXTRA_ERROR)!! as Throwable) + else -> Canceled() + } + } + } + + object SavedCards { + + sealed class Result + class Success(val selectedCardId: String?, val cardListChanged: Boolean) : Result() + class Canceled : Result() + class Error(val error: Throwable) : Result() + + object Contract : ActivityResultContract() { - override fun createIntent(context: Context, input: AttachCardOptions): Intent = - BaseAcquiringActivity.createIntent(context, input.apply { - setTerminalParams(terminalKey, publicKey) - }, AttachCardActivity::class.java) + override fun createIntent(context: Context, input: SavedCardsOptions): Intent = + BaseAcquiringActivity.createIntent(context, input.apply { + setTerminalParams(terminalKey, publicKey) + }, CardsListActivity::class.java) - override fun parseResult(resultCode: Int, intent: Intent?): String? = when (resultCode) { - AppCompatActivity.RESULT_OK -> intent?.getStringExtra(EXTRA_CARD_ID)!! - else -> null + override fun parseResult(resultCode: Int, intent: Intent?): Result = when (resultCode) { + AppCompatActivity.RESULT_OK -> Success( + intent?.getStringExtra(EXTRA_CARD_ID), + intent?.getBooleanExtra(EXTRA_CARD_LIST_CHANGED, false)!!) + RESULT_ERROR -> Error(intent!!.getSerializableExtra(EXTRA_ERROR)!! as Throwable) + else -> Canceled() + } } } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/models/options/screen/SavedCardsOptions.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/models/options/screen/SavedCardsOptions.kt index 89d2c7b7..e376a2b5 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/models/options/screen/SavedCardsOptions.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/models/options/screen/SavedCardsOptions.kt @@ -2,6 +2,7 @@ package ru.tinkoff.acquiring.sdk.models.options.screen import android.os.Parcel import android.os.Parcelable +import ru.tinkoff.acquiring.sdk.TinkoffAcquiring /** * Настройки экрана сохраненных карт @@ -10,7 +11,10 @@ import android.os.Parcelable */ class SavedCardsOptions : BaseCardsOptions, Parcelable { - constructor() : super() + /** + * [TinkoffAcquiring.savedCardsOptions] + */ + internal constructor() : super() private constructor(parcel: Parcel) : super(parcel) override fun setOptions(options: SavedCardsOptions.() -> Unit): SavedCardsOptions { diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt index 126277bf..42922181 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt @@ -38,10 +38,6 @@ class CardsListAdapter( notifyItemRemoved(indexAt) } - fun onAddCard(card: CardItemUiModel) { - //TODO после задачи на добавление карты - } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CardViewHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.acq_card_list_item, parent, false) as View @@ -84,7 +80,7 @@ class CardsListAdapter( cardLogo.setImageResource(CardLogoProvider.getCardLogo(card.pan)) cardNameView.text = itemView.context.getString( R.string.acq_cardlist_bankname, - card.bankName, + card.bankName.orEmpty(), card.tail ) bindDeleteVisibility(card.showDelete) diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt index 9929c490..68ee182b 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt @@ -3,7 +3,6 @@ package ru.tinkoff.acquiring.sdk.redesign.cards.list.presentation import androidx.annotation.VisibleForTesting import androidx.lifecycle.ViewModel import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import ru.tinkoff.acquiring.sdk.AcquiringSdk import ru.tinkoff.acquiring.sdk.models.Card @@ -46,7 +45,7 @@ internal class CardsListViewModel( stateFlow.tryEmit(CardsListState.Shimmer) manager.launchOnBackground { if (customerKey == null) { - stateFlow.tryEmit(CardsListState.Error) + stateFlow.tryEmit(CardsListState.Error(Throwable())) return@launchOnBackground } @@ -81,8 +80,8 @@ internal class CardsListViewModel( .onStart { eventFlow.value = CardListEvent.RemoveCardProgress } .collect { it.process( - onSuccess = { r -> - handleDeleteCard(checkNotNull(r.cardId?.toString())) + onSuccess = { + handleDeleteCard(model) deleteJob?.cancel() }, onFailure = { @@ -139,20 +138,20 @@ internal class CardsListViewModel( } private fun handleGetCardListError(it: Exception) { - stateFlow.value = CardsListState.Error + stateFlow.value = CardsListState.Error(it) } - private fun handleDeleteCard(deletedCardId: String) { + private fun handleDeleteCard(deletedCard: CardItemUiModel) { val list = checkNotNull((stateFlow.value as? CardsListState.Content)?.cards).toMutableList() - val indexAt = list.indexOfFirst { it.id == deletedCardId } + val indexAt = list.indexOfFirst { it.id == deletedCard.id } list.removeAt(indexAt) if (list.isEmpty()) { stateFlow.value = CardsListState.Empty - eventFlow.value = CardListEvent.RemoveCardSuccess(null) + eventFlow.value = CardListEvent.RemoveCardSuccess(deletedCard, null) } else { stateFlow.update { CardsListState.Content(it.mode, true, list) } - eventFlow.value = CardListEvent.RemoveCardSuccess(indexAt) + eventFlow.value = CardListEvent.RemoveCardSuccess(deletedCard, indexAt) } } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt index f4df6e9a..8703386b 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt @@ -1,5 +1,7 @@ package ru.tinkoff.acquiring.sdk.redesign.cards.list.ui +import android.app.Activity +import android.content.Intent import android.os.Bundle import android.view.Menu import android.view.MenuItem @@ -17,12 +19,18 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch import ru.tinkoff.acquiring.sdk.R +import ru.tinkoff.acquiring.sdk.TinkoffAcquiring +import ru.tinkoff.acquiring.sdk.TinkoffAcquiring.AttachCard +import ru.tinkoff.acquiring.sdk.models.options.screen.AttachCardOptions import ru.tinkoff.acquiring.sdk.models.options.screen.SavedCardsOptions import ru.tinkoff.acquiring.sdk.redesign.cards.list.adapters.CardsListAdapter +import ru.tinkoff.acquiring.sdk.redesign.cards.list.models.CardItemUiModel import ru.tinkoff.acquiring.sdk.redesign.cards.list.presentation.CardsListViewModel import ru.tinkoff.acquiring.sdk.redesign.common.util.AcqShimmerAnimator import ru.tinkoff.acquiring.sdk.ui.activities.TransparentActivity import ru.tinkoff.acquiring.sdk.utils.AcqSnackBarHelper +import ru.tinkoff.acquiring.sdk.utils.ErrorResolver +import ru.tinkoff.acquiring.sdk.utils.lazyView import ru.tinkoff.acquiring.sdk.utils.showById internal class CardsListActivity : TransparentActivity() { @@ -38,22 +46,35 @@ internal class CardsListActivity : TransparentActivity() { private var mode = CardListMode.STUB - private val stubImage: ImageView by lazy(LazyThreadSafetyMode.NONE) { - findViewById(R.id.acq_stub_img) - } - private val stubTitleView: TextView by lazy(LazyThreadSafetyMode.NONE) { - findViewById(R.id.acq_stub_title) - } - private val stubSubtitleView: TextView by lazy(LazyThreadSafetyMode.NONE) { - findViewById(R.id.acq_stub_subtitle) - } - private val stubButtonView: TextView by lazy(LazyThreadSafetyMode.NONE) { - findViewById(R.id.acq_stub_retry_button) - } - private val addNewCard: TextView by lazy(LazyThreadSafetyMode.NONE) { - findViewById(R.id.acq_add_new_card) + private val stubImage: ImageView by lazyView(R.id.acq_stub_img) + private val stubTitleView: TextView by lazyView(R.id.acq_stub_title) + private val stubSubtitleView: TextView by lazyView(R.id.acq_stub_subtitle) + private val stubButtonView: TextView by lazyView(R.id.acq_stub_retry_button) + private val addNewCard: TextView by lazyView(R.id.acq_add_new_card) + + private val attachCard = registerForActivityResult(AttachCard.Contract) { result -> + when (result) { + is AttachCard.Success -> { + attachedCardId = result.cardId + + viewModel.loadData( + savedCardsOptions.customer.customerKey, + options.features.showOnlyRecurrentCards) + } + is AttachCard.Error -> showErrorDialog( + getString(R.string.acq_cardlist_alert_label), + ErrorResolver.resolve(result.error, getString(R.string.acq_cardlist_stub_description)), + getString(R.string.acq_cardlist_alert_access) + ) + else -> Unit + } } + private var selectedCardId: String? = null + private var isCardListChanged = false + private var isErrorOccurred = false + private var attachedCardId: String? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) savedCardsOptions = options as SavedCardsOptions @@ -68,6 +89,9 @@ internal class CardsListActivity : TransparentActivity() { initToolbar() initViews() subscribeOnState() + + // todo + // options.features.selectedCardId } override fun onCreateOptionsMenu(menu: Menu): Boolean { @@ -103,13 +127,14 @@ internal class CardsListActivity : TransparentActivity() { } private fun initViews() { - recyclerView = findViewById(R.id.acq_card_list_view) + recyclerView = findViewById(R.id.acq_card_list_view) viewFlipper = findViewById(R.id.acq_view_flipper) cardShimmer = viewFlipper.findViewById(R.id.acq_card_list_shimmer) cardsListAdapter = CardsListAdapter(onDeleteClick = { viewModel.deleteCard(it, savedCardsOptions.customer.customerKey!!) }) recyclerView.adapter = cardsListAdapter + addNewCard.setOnClickListener { startAttachCard() } snackBarHelper = AcqSnackBarHelper(findViewById(R.id.acq_card_list_root)) } @@ -136,6 +161,7 @@ internal class CardsListActivity : TransparentActivity() { viewModel.stateUiFlow.collectLatest { when (it) { is CardsListState.Content -> { + it.cards.find { card -> card.id == attachedCardId }?.handleCardAttached() viewFlipper.showById(R.id.acq_card_list_content) cardsListAdapter.setCards(it.cards) } @@ -152,9 +178,7 @@ internal class CardsListActivity : TransparentActivity() { subTitleTextRes = R.string.acq_cardlist_stub_description, buttonTextRes = R.string.acq_cardlist_alert_access ) - stubButtonView.setOnClickListener { - finish() - } + stubButtonView.setOnClickListener { _ -> finishWithError(it.throwable) } } is CardsListState.Empty -> { showStub( @@ -163,9 +187,7 @@ internal class CardsListActivity : TransparentActivity() { subTitleTextRes = R.string.acq_cardlist_description, buttonTextRes = R.string.acq_cardlist_button_add ) - stubButtonView.setOnClickListener { - //todo навигация с результатом о привязке карты - } + stubButtonView.setOnClickListener { startAttachCard() } } is CardsListState.NoNetwork -> { showStub( @@ -186,6 +208,23 @@ internal class CardsListActivity : TransparentActivity() { } } + private fun startAttachCard() { + attachCard.launch(AttachCardOptions().setOptions { + setTerminalParams(savedCardsOptions.terminalKey, savedCardsOptions.publicKey) + customerOptions { + checkType = savedCardsOptions.customer.checkType + customerKey = savedCardsOptions.customer.customerKey + } + features = savedCardsOptions.features + }) + } + + private fun CardItemUiModel.handleCardAttached() { + attachedCardId = null + snackBarHelper.showWithIcon(R.drawable.acq_ic_card_sparkle, + getString(R.string.acq_cardlist_snackbar_add, tail)) + } + private fun CoroutineScope.subscribeOnEvents() { launch { viewModel.eventFlow.filterNotNull().collect { @@ -194,19 +233,18 @@ internal class CardsListActivity : TransparentActivity() { when (it) { is CardListEvent.RemoveCardProgress -> { - snackBarHelper.show( - R.string.acq_cardlist_snackbar_remove_progress, true - ) + snackBarHelper.showProgress(R.string.acq_cardlist_snackbar_remove_progress) } is CardListEvent.RemoveCardSuccess -> { it.indexAt?.let(cardsListAdapter::onRemoveCard) - snackBarHelper.hide() + snackBarHelper.showWithIcon(R.drawable.acq_ic_card_sparkle, + getString(R.string.acq_cardlist_snackbar_remove, it.deletedCard.tail)) } is CardListEvent.ShowError -> { - // TODO после задачи на диалог с ошибками - snackBarHelper.show( - "Произошла ошибка" - ) + showErrorDialog( + R.string.acq_cardlist_alert_label, + R.string.acq_cardlist_stub_description, + R.string.acq_cardlist_alert_access) } } } @@ -231,4 +269,19 @@ internal class CardsListActivity : TransparentActivity() { stubSubtitleView.setText(subTitleTextRes) stubButtonView.setText(buttonTextRes) } + + override fun finishWithError(throwable: Throwable) { + isErrorOccurred = true + super.finishWithError(throwable) + } + + override fun finish() { + if (!isErrorOccurred) { + val intent = Intent() + intent.putExtra(TinkoffAcquiring.EXTRA_CARD_ID, selectedCardId) + intent.putExtra(TinkoffAcquiring.EXTRA_CARD_LIST_CHANGED, isCardListChanged) + setResult(Activity.RESULT_OK, intent) + } + super.finish() + } } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt index 13a4d0c5..bcdfd80e 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt @@ -8,7 +8,7 @@ import ru.tinkoff.acquiring.sdk.redesign.cards.list.models.CardItemUiModel sealed class CardsListState(val mode: CardListMode, val isInternal: Boolean = false) { object Shimmer : CardsListState(CardListMode.STUB) object Empty : CardsListState(CardListMode.STUB) - object Error : CardsListState(CardListMode.STUB) + class Error(val throwable: Throwable) : CardsListState(CardListMode.STUB) object NoNetwork : CardsListState(CardListMode.STUB) class Content( @@ -20,7 +20,11 @@ sealed class CardsListState(val mode: CardListMode, val isInternal: Boolean = fa sealed class CardListEvent { object RemoveCardProgress : CardListEvent() - class RemoveCardSuccess(val indexAt: Int?) : CardListEvent() + + class RemoveCardSuccess( + val deletedCard: CardItemUiModel, + val indexAt: Int?) : CardListEvent() + object ShowError : CardListEvent() } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt index c50e6510..cb0f93f6 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt @@ -74,6 +74,8 @@ internal class CardDataInputFragment : Fragment() { expiry_date_input.requestViewFocus() } } + + onDataChanged() } } @@ -92,6 +94,8 @@ internal class CardDataInputFragment : Fragment() { errorHighlighted = true } } + + onDataChanged() } } @@ -114,10 +118,14 @@ internal class CardDataInputFragment : Fragment() { errorHighlighted = true } } + + onDataChanged() } } cardNumberInput.requestViewFocus() + + onDataChanged() } override fun onAttach(context: Context) { @@ -168,12 +176,25 @@ internal class CardDataInputFragment : Fragment() { return result } + fun isValid(): Boolean = CardValidator.validateCardNumber(cardNumber) && + CardValidator.validateExpireDate(expiryDate, false) && + CardValidator.validateSecurityCode(cvc) + + private fun onDataChanged() { + ((parentFragment as? OnCardDataChanged) ?: (activity as? OnCardDataChanged)) + ?.onCardDataChanged(isValid()) + } + fun clearInput() { cardNumberInput.text = "" expiryDateInput.text = "" cvcInput.text = "" } + fun interface OnCardDataChanged { + fun onCardDataChanged(isValid: Boolean) + } + private companion object { const val MIN_LENGTH_FOR_AUTO_SWITCH = 16 diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/AttachCardActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/AttachCardActivity.kt index 7ca6f5fa..2ad7ef8b 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/AttachCardActivity.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/AttachCardActivity.kt @@ -16,9 +16,12 @@ package ru.tinkoff.acquiring.sdk.ui.activities +import android.app.Activity +import android.content.Intent import android.os.Bundle import androidx.lifecycle.Observer import ru.tinkoff.acquiring.sdk.R +import ru.tinkoff.acquiring.sdk.TinkoffAcquiring import ru.tinkoff.acquiring.sdk.models.ErrorButtonClickedEvent import ru.tinkoff.acquiring.sdk.models.ErrorScreenState import ru.tinkoff.acquiring.sdk.models.FinishWithErrorScreenState @@ -28,6 +31,7 @@ import ru.tinkoff.acquiring.sdk.models.ScreenState import ru.tinkoff.acquiring.sdk.models.SingleEvent import ru.tinkoff.acquiring.sdk.models.ThreeDsScreenState import ru.tinkoff.acquiring.sdk.models.options.screen.AttachCardOptions +import ru.tinkoff.acquiring.sdk.models.result.CardResult import ru.tinkoff.acquiring.sdk.threeds.ThreeDsHelper import ru.tinkoff.acquiring.sdk.ui.fragments.AttachCardFragment import ru.tinkoff.acquiring.sdk.ui.fragments.LoopConfirmationFragment @@ -60,7 +64,7 @@ internal class AttachCardActivity : TransparentActivity() { setSupportActionBar(findViewById(R.id.acq_toolbar)) supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayShowHomeEnabled(true) - supportActionBar?.setTitle(R.string.acq_attach_card_title) + supportActionBar?.setTitle(R.string.acq_cardlist_addcard_title) } private fun observeLiveData() { @@ -71,12 +75,23 @@ internal class AttachCardActivity : TransparentActivity() { } } + private fun finishWithSuccess(result: CardResult) { + val intent = Intent() + intent.putExtra(TinkoffAcquiring.EXTRA_CARD_ID, result.cardId) + setResult(Activity.RESULT_OK, intent) + finish() + } + private fun handleScreenState(screenState: ScreenState) { when (screenState) { is FinishWithErrorScreenState -> finishWithError(screenState.error) is ErrorScreenState -> { if (supportFragmentManager.findFragmentById(R.id.acq_activity_fl_container) !is LoopConfirmationFragment) { - showErrorDialog(message = screenState.message) { + showErrorDialog( + getString(R.string.acq_attach_card_error), + screenState.message, + getString(R.string.acq_cardlist_alert_access) + ) { attachCardViewModel.createEvent(ErrorButtonClickedEvent) } } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/BaseAcquiringActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/BaseAcquiringActivity.kt index 3c6dc24e..ec0ef3b7 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/BaseAcquiringActivity.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/BaseAcquiringActivity.kt @@ -132,28 +132,28 @@ internal open class BaseAcquiringActivity : AppCompatActivity() { } protected fun showErrorDialog( - @StringRes title: Int = R.string.acq_error, - @StringRes message: Int, - @StringRes buttonText: Int = R.string.acq_ok, + @StringRes title: Int, + @StringRes message: Int?, + @StringRes buttonText: Int, onButtonClick: (() -> Unit)? = null ) { AlertDialog.Builder(this) .setTitle(title) - .setMessage(message) + .apply { message?.let { setMessage(it) } } .setPositiveButton(buttonText) { _, _ -> onButtonClick?.invoke() }.show() } protected fun showErrorDialog( - title: String = getString(R.string.acq_error), - message: String, - buttonText: String = getString(R.string.acq_ok), + title: String, + message: String?, + buttonText: String, onButtonClick: (() -> Unit)? = null ) { AlertDialog.Builder(this) .setTitle(title) - .setMessage(message) + .apply { message?.let { setMessage(it) } } .setPositiveButton(buttonText) { _, _ -> onButtonClick?.invoke() }.show() diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/LoaderButton.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/LoaderButton.kt index e1a6934f..3dc75b32 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/LoaderButton.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/LoaderButton.kt @@ -39,8 +39,8 @@ constructor( val textView = TextView(context).apply { textSize = 16f - setTextColor(ColorStateList.valueOf(ResourcesCompat.getColor( - context.resources, R.color.acq_colorButtonText, context.theme))) + setTextColor(ResourcesCompat.getColorStateList( + context.resources, R.color.acq_button_text_selector, context.theme)) } val loader = ProgressBar(context).apply { diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/fragments/AttachCardFragment.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/fragments/AttachCardFragment.kt index 315ffaa8..68d12c7c 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/fragments/AttachCardFragment.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/fragments/AttachCardFragment.kt @@ -40,7 +40,8 @@ import ru.tinkoff.acquiring.sdk.viewmodel.AttachCardViewModel /** * @author Mariya Chernyadieva */ -internal class AttachCardFragment : BaseAcquiringFragment() { +internal class AttachCardFragment : BaseAcquiringFragment(), + CardDataInputFragment.OnCardDataChanged { private lateinit var attachCardViewModel: AttachCardViewModel private lateinit var attachCardOptions: AttachCardOptions @@ -76,6 +77,10 @@ internal class AttachCardFragment : BaseAcquiringFragment() { } } + override fun onCardDataChanged(isValid: Boolean) { + attachButton.isEnabled = isValid + } + private fun observeLiveData() { with(attachCardViewModel) { loadStateLiveData.observe(viewLifecycleOwner) { handleLoadState(it) } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBarHelper.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBarHelper.kt index c977b7be..374cf3ea 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBarHelper.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBarHelper.kt @@ -2,10 +2,12 @@ package ru.tinkoff.acquiring.sdk.utils import android.view.LayoutInflater import android.view.View +import android.widget.ImageView import android.widget.ProgressBar import android.widget.TextView +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes import androidx.core.content.ContextCompat -import androidx.core.view.isVisible import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar.SnackbarLayout import ru.tinkoff.acquiring.sdk.R @@ -18,16 +20,15 @@ class AcqSnackBarHelper(private val view: View) { private var snackbar: Snackbar? = null - fun show(textValue: String, showProgressBar: Boolean = false) { + fun showProgress(textValue: String) { snackbar?.takeIf { it.isShown }?.dismiss() - val bar = Snackbar.make(view, "", Snackbar.LENGTH_INDEFINITE).apply { snackbar = this } + val bar = Snackbar.make(view, "", Snackbar.LENGTH_SHORT).apply { snackbar = this } val customSnackView: View = - LayoutInflater.from(view.context).inflate(R.layout.acq_snackbar_layout, null) + LayoutInflater.from(view.context).inflate(R.layout.acq_snackbar_progress_layout, null) val textView = customSnackView.findViewById(R.id.acq_snackbar_text) val progressBar = customSnackView.findViewById(R.id.acq_snackbar_progress_bar) val snackbarLayout = bar.view as SnackbarLayout textView.text = textValue - progressBar.isVisible = showProgressBar snackbarLayout.addView(customSnackView, 0) @@ -44,11 +45,47 @@ class AcqSnackBarHelper(private val view: View) { bar.show() } - fun show(textValue: Int, showProgressBar: Boolean = false) { - show(view.context.getString(textValue), showProgressBar) + fun showProgress(@StringRes textValue: Int) { + showProgress(view.context.getString(textValue)) } - fun hide() { - snackbar?.dismiss() + fun showWithIcon(@DrawableRes iconRes: Int, textValue: String) { + snackbar?.takeIf { it.isShown }?.dismiss() + val bar = Snackbar.make(view, "", Snackbar.LENGTH_SHORT).apply { snackbar = this } + val customSnackView: View = + LayoutInflater.from(view.context).inflate(R.layout.acq_snackbar_with_icon_layout, null) + val textView = customSnackView.findViewById(R.id.acq_snackbar_text) + val imageView = customSnackView.findViewById(R.id.acq_snackbar_icon) + imageView.setImageResource(iconRes) + val snackbarLayout = bar.view as SnackbarLayout + textView.text = textValue + + snackbarLayout.addView(customSnackView, 0) + + snackbarLayout.setBackgroundColor( + ContextCompat.getColor(view.context, android.R.color.transparent) + ) + snackbarLayout.setPadding( + view.context.dpToPx(16), + 0, + view.context.dpToPx(16), + view.context.dpToPx(24) + ) + + bar.show() + } + + fun showWithIcon(@DrawableRes iconRes: Int, @StringRes textValue: Int) { + showWithIcon(iconRes, view.context.getString(textValue)) + } + + fun hide(delay: Long = 0) { + if (delay == 0L) { + snackbar?.dismiss() + } else { + view.postDelayed({ + snackbar?.dismiss() + }, delay) + } } } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/ErrorResolver.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/ErrorResolver.kt new file mode 100644 index 00000000..b16852fc --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/ErrorResolver.kt @@ -0,0 +1,18 @@ +package ru.tinkoff.acquiring.sdk.utils + +import ru.tinkoff.acquiring.sdk.exceptions.AcquiringApiException +import ru.tinkoff.acquiring.sdk.network.AcquiringApi + +object ErrorResolver { + + fun resolve(throwable: Throwable, fallbackMessage: String) = when (throwable) { + is AcquiringApiException -> { + val errorCode = throwable.response?.errorCode + if (errorCode != null && (AcquiringApi.errorCodesForUserShowing.contains(errorCode))) { + throwable.response?.message ?: fallbackMessage + } else fallbackMessage + + } + else -> throwable.message ?: fallbackMessage + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/AttachCardViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/AttachCardViewModel.kt index 6c509f9d..fa9219f6 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/AttachCardViewModel.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/AttachCardViewModel.kt @@ -23,7 +23,6 @@ import ru.tinkoff.acquiring.sdk.AcquiringSdk import ru.tinkoff.acquiring.sdk.R import ru.tinkoff.acquiring.sdk.exceptions.AcquiringApiException import ru.tinkoff.acquiring.sdk.exceptions.AcquiringSdkException -import ru.tinkoff.acquiring.sdk.localization.AsdkLocalization import ru.tinkoff.acquiring.sdk.models.DefaultScreenState import ru.tinkoff.acquiring.sdk.models.ErrorScreenState import ru.tinkoff.acquiring.sdk.models.LoadedState @@ -34,6 +33,7 @@ import ru.tinkoff.acquiring.sdk.models.enums.ResponseStatus import ru.tinkoff.acquiring.sdk.models.paysources.CardData import ru.tinkoff.acquiring.sdk.models.result.CardResult import ru.tinkoff.acquiring.sdk.network.AcquiringApi +import ru.tinkoff.acquiring.sdk.utils.ErrorResolver /** * @author Mariya Chernyadieva @@ -106,7 +106,8 @@ internal class AttachCardViewModel( if (needHandleErrorsInSdk && it is AcquiringApiException) { if (it.response != null && AcquiringApi.errorCodesAttachedCard.contains(it.response!!.errorCode)) { changeScreenState(LoadedState) - changeScreenState(ErrorScreenState(context.getString(R.string.acq_attach_card_error))) + changeScreenState(ErrorScreenState(ErrorResolver.resolve(it, + context.getString(R.string.acq_attach_card_error)))) } else handleException(it) } else handleException(it) } diff --git a/ui/src/main/res/color/acq_button_text_selector.xml b/ui/src/main/res/color/acq_button_text_selector.xml new file mode 100644 index 00000000..88aa77ed --- /dev/null +++ b/ui/src/main/res/color/acq_button_text_selector.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/drawable/acq_button_yellow_bg.xml b/ui/src/main/res/drawable/acq_button_yellow_bg.xml index 7243b782..e736e007 100644 --- a/ui/src/main/res/drawable/acq_button_yellow_bg.xml +++ b/ui/src/main/res/drawable/acq_button_yellow_bg.xml @@ -14,8 +14,7 @@ ~ limitations under the License. --> - + @@ -24,13 +23,6 @@ - - - - - - + - \ No newline at end of file + \ No newline at end of file diff --git a/ui/src/main/res/drawable/acq_button_yellow_bg_ripple.xml b/ui/src/main/res/drawable/acq_button_yellow_bg_ripple.xml new file mode 100644 index 00000000..b0798a8a --- /dev/null +++ b/ui/src/main/res/drawable/acq_button_yellow_bg_ripple.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/drawable/acq_ic_card_sparkle.xml b/ui/src/main/res/drawable/acq_ic_card_sparkle.xml new file mode 100644 index 00000000..b706955c --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_sparkle.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_snackbar_icon_bg.xml b/ui/src/main/res/drawable/acq_ic_snackbar_icon_bg.xml new file mode 100644 index 00000000..f694a564 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_snackbar_icon_bg.xml @@ -0,0 +1,10 @@ + + + diff --git a/ui/src/main/res/drawable/acq_selectable_item_background_circle.xml b/ui/src/main/res/drawable/acq_selectable_item_background_circle.xml new file mode 100644 index 00000000..88566349 --- /dev/null +++ b/ui/src/main/res/drawable/acq_selectable_item_background_circle.xml @@ -0,0 +1,11 @@ + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_card_list_content.xml b/ui/src/main/res/layout/acq_card_list_content.xml index 84faac9a..8aa450ec 100644 --- a/ui/src/main/res/layout/acq_card_list_content.xml +++ b/ui/src/main/res/layout/acq_card_list_content.xml @@ -39,6 +39,7 @@ style="@style/TextAppearance.AppCompat.Body1" android:layout_width="match_parent" android:layout_height="wrap_content" + android:background="?selectableItemBackground" android:drawablePadding="16dp" android:fontFamily="@font/roboto_regular" android:gravity="center_vertical" diff --git a/ui/src/main/res/layout/acq_card_list_item.xml b/ui/src/main/res/layout/acq_card_list_item.xml index cfb70c25..683cb576 100644 --- a/ui/src/main/res/layout/acq_card_list_item.xml +++ b/ui/src/main/res/layout/acq_card_list_item.xml @@ -25,7 +25,7 @@ android:layout_height="26dp" android:layout_gravity="center_vertical" android:layout_marginStart="16dp" - tools:src="@drawable/acq_ic_card_mir_tinkoff"/> + tools:src="@drawable/acq_ic_card_mir_tinkoff" /> + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_fragment_attach_card.xml b/ui/src/main/res/layout/acq_fragment_attach_card.xml index 2fa14af7..8319c7cf 100644 --- a/ui/src/main/res/layout/acq_fragment_attach_card.xml +++ b/ui/src/main/res/layout/acq_fragment_attach_card.xml @@ -34,12 +34,15 @@ android:layout_height="wrap_content" android:layout_alignParentBottom="true" android:layout_marginBottom="24dp" - app:acq_text="@string/acq_attach_card_btn" /> + app:acq_text="@string/acq_addcard_button_add" /> \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_snackbar_layout.xml b/ui/src/main/res/layout/acq_snackbar_progress_layout.xml similarity index 100% rename from ui/src/main/res/layout/acq_snackbar_layout.xml rename to ui/src/main/res/layout/acq_snackbar_progress_layout.xml diff --git a/ui/src/main/res/layout/acq_snackbar_view.xml b/ui/src/main/res/layout/acq_snackbar_view.xml deleted file mode 100644 index 77d9ef65..00000000 --- a/ui/src/main/res/layout/acq_snackbar_view.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_snackbar_with_icon_layout.xml b/ui/src/main/res/layout/acq_snackbar_with_icon_layout.xml new file mode 100644 index 00000000..9f38a829 --- /dev/null +++ b/ui/src/main/res/layout/acq_snackbar_with_icon_layout.xml @@ -0,0 +1,36 @@ + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/values-night/colors.xml b/ui/src/main/res/values-night/colors.xml index 317cf273..cb81b589 100644 --- a/ui/src/main/res/values-night/colors.xml +++ b/ui/src/main/res/values-night/colors.xml @@ -18,6 +18,7 @@ #1C1C1E #333333 + #0FFFFFFF @android:color/transparent @color/acq_colorMainDark #FFFFFF @@ -29,6 +30,7 @@ #727272 @color/acq_colorMain + #4DFFFFFF #1affffff #26ffffff diff --git a/ui/src/main/res/values-ru/strings.xml b/ui/src/main/res/values-ru/strings.xml index 9bd6418c..c63534c0 100644 --- a/ui/src/main/res/values-ru/strings.xml +++ b/ui/src/main/res/values-ru/strings.xml @@ -38,11 +38,11 @@ Добавить Добавить новую - Оплата картой + Добавить карту Номер Срок Код - Добавить + Добавить Карта •%1$s добавлена Карта •%1$s удалена @@ -51,9 +51,7 @@ Изменить Готово - Ошибка Ошибка добавления карты - Понятно Доступен %d символ diff --git a/ui/src/main/res/values/colors.xml b/ui/src/main/res/values/colors.xml index 885409b8..9a04274d 100644 --- a/ui/src/main/res/values/colors.xml +++ b/ui/src/main/res/values/colors.xml @@ -21,8 +21,8 @@ #428BF9 #3e4757 #FFDD2D - #55ffdd2d - #F6F7F8 + #FFCD33 + #08001024 #000000 #333333 #ff9299a2 @@ -54,9 +54,9 @@ #08001024 #0f001024 - #333333 #333333 + #38001024 #FFDD2D diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 16b1e3ab..96d1f8a3 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -29,11 +29,11 @@ %1$s • %2$s Your cards - Attach card + Attach card Number Expiry date Code - Attach + Attach Try again in a couple of minutes @@ -48,14 +48,12 @@ Card •%1$s added Card •%1$s deleted - delete in progress + Delete in progress Add new Сhange Done - Error Error attaching card - OK %d symbol available diff --git a/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsDeleteViewModelTest.kt b/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsDeleteViewModelTest.kt index d41c700d..b6cd2fb0 100644 --- a/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsDeleteViewModelTest.kt +++ b/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsDeleteViewModelTest.kt @@ -1,14 +1,10 @@ package ru.tinkoff.acquiring.sdk.redesign.cards.list import app.cash.turbine.test -import awaitWithConditionOrNext import common.MutableCollector import common.assertByClassName import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.test.runTest -import org.junit.Assert import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.doReturn @@ -48,12 +44,13 @@ internal class CardsDeleteViewModelTest { eventCollector.takeValues(2) setResponse(RequestResult.Success(RemoveCardResponse(1))) - vm.deleteCard(createCard("1"), "") + val card = createCard("1") + vm.deleteCard(card, "") eventCollector.joinWithTimeout() eventCollector.flow.test { assertByClassName(CardListEvent.RemoveCardProgress, awaitItem()) - assertByClassName(CardListEvent.RemoveCardSuccess(null), awaitItem()) + assertByClassName(CardListEvent.RemoveCardSuccess(card, null), awaitItem()) awaitComplete() } } From 8f4db5c679a5dde91246759a33f68f1059584e3b Mon Sep 17 00:00:00 2001 From: "i.khafizov" Date: Wed, 7 Dec 2022 20:20:55 +0400 Subject: [PATCH 020/126] MC-7550 bottom sheet dialogs --- sample/build.gradle | 1 + .../dialog/OpenBankProgressDialogFragment.kt | 37 ++++++++++++++ .../dialog/PaymentFailureDialogFragment.kt | 39 ++++++++++++++ ...tFailureInsufficientFundsDialogFragment.kt | 48 +++++++++++++++++ .../dialog/PaymentSuccessDialogFragment.kt | 37 ++++++++++++++ .../sdk/ui/customview/LoaderButton.kt | 21 ++++++-- .../acq_secondary_button_text_selector.xml | 7 +++ .../main/res/drawable/acq_bottom_sheet_bg.xml | 25 +++++++++ .../main/res/drawable/acq_button_flat_bg.xml | 42 +++++++++++++++ ...ripple.xml => acq_button_secondary_bg.xml} | 21 +++++--- .../res/drawable/acq_button_yellow_bg.xml | 12 ++++- .../drawable/acq_ic_check_circle_positive.xml | 26 ++++++++++ .../main/res/drawable/acq_ic_cross_circle.xml | 27 ++++++++++ .../acq_fragment_open_bank_progress.xml | 45 ++++++++++++++++ .../layout/acq_fragment_payment_failure.xml | 34 +++++++++++++ ...ent_payment_failure_insufficient_funds.xml | 51 +++++++++++++++++++ .../layout/acq_fragment_payment_success.xml | 34 +++++++++++++ ui/src/main/res/values/attrs.xml | 6 ++- ui/src/main/res/values/colors.xml | 2 + ui/src/main/res/values/styles.xml | 20 +++++++- 20 files changed, 521 insertions(+), 14 deletions(-) create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/OpenBankProgressDialogFragment.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentFailureDialogFragment.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentFailureInsufficientFundsDialogFragment.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentSuccessDialogFragment.kt create mode 100644 ui/src/main/res/color/acq_secondary_button_text_selector.xml create mode 100644 ui/src/main/res/drawable/acq_bottom_sheet_bg.xml create mode 100644 ui/src/main/res/drawable/acq_button_flat_bg.xml rename ui/src/main/res/drawable/{acq_button_yellow_bg_ripple.xml => acq_button_secondary_bg.xml} (57%) create mode 100644 ui/src/main/res/drawable/acq_ic_check_circle_positive.xml create mode 100644 ui/src/main/res/drawable/acq_ic_cross_circle.xml create mode 100644 ui/src/main/res/layout/acq_fragment_open_bank_progress.xml create mode 100644 ui/src/main/res/layout/acq_fragment_payment_failure.xml create mode 100644 ui/src/main/res/layout/acq_fragment_payment_failure_insufficient_funds.xml create mode 100644 ui/src/main/res/layout/acq_fragment_payment_success.xml diff --git a/sample/build.gradle b/sample/build.gradle index b1b45d67..7e5d1403 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -39,6 +39,7 @@ dependencies { implementation "androidx.preference:preference:$preferenceVersion" implementation "androidx.appcompat:appcompat:$appCompatVersion" implementation "androidx.constraintlayout:constraintlayout:2.1.4" + implementation "com.google.android.material:material:${materialVersion}" implementation "com.google.code.gson:gson:$gsonVersion" testImplementation 'junit:junit:4.13' diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/OpenBankProgressDialogFragment.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/OpenBankProgressDialogFragment.kt new file mode 100644 index 00000000..cb322997 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/OpenBankProgressDialogFragment.kt @@ -0,0 +1,37 @@ +package ru.tinkoff.acquiring.sdk.redesign.dialog + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import ru.tinkoff.acquiring.sdk.R +import ru.tinkoff.acquiring.sdk.ui.customview.LoaderButton +import ru.tinkoff.acquiring.sdk.utils.lazyView + +class OpenBankProgressDialogFragment : BottomSheetDialogFragment() { + + private val buttonCancel: LoaderButton by lazyView(R.id.acq_button_cancel) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NO_FRAME, theme) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = + inflater.inflate(R.layout.acq_fragment_open_bank_progress, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + buttonCancel.setOnClickListener { onCancel() } + } + + private fun onCancel() { + ((parentFragment as? OnCancel) ?: (activity as? OnCancel))?.onOpenBankProgressCancel(this) + } + + fun interface OnCancel { + fun onOpenBankProgressCancel(fragment: OpenBankProgressDialogFragment) + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentFailureDialogFragment.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentFailureDialogFragment.kt new file mode 100644 index 00000000..173e1d6f --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentFailureDialogFragment.kt @@ -0,0 +1,39 @@ +package ru.tinkoff.acquiring.sdk.redesign.dialog + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import ru.tinkoff.acquiring.sdk.R +import ru.tinkoff.acquiring.sdk.ui.customview.LoaderButton +import ru.tinkoff.acquiring.sdk.utils.lazyView + +class PaymentFailureDialogFragment : BottomSheetDialogFragment() { + + private val buttonChooseAnotherMethod: LoaderButton + by lazyView(R.id.acq_button_choose_another_method) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NO_FRAME, theme) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = + inflater.inflate(R.layout.acq_fragment_payment_failure, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + buttonChooseAnotherMethod.setOnClickListener { onChooseAnotherMethod() } + } + + private fun onChooseAnotherMethod() { + ((parentFragment as? OnChooseAnotherMethod) ?: (activity as? OnChooseAnotherMethod)) + ?.onPaymentFailureChooseAnotherMethod(this) + } + + fun interface OnChooseAnotherMethod { + fun onPaymentFailureChooseAnotherMethod(fragment: PaymentFailureDialogFragment) + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentFailureInsufficientFundsDialogFragment.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentFailureInsufficientFundsDialogFragment.kt new file mode 100644 index 00000000..41074520 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentFailureInsufficientFundsDialogFragment.kt @@ -0,0 +1,48 @@ +package ru.tinkoff.acquiring.sdk.redesign.dialog + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import ru.tinkoff.acquiring.sdk.R +import ru.tinkoff.acquiring.sdk.ui.customview.LoaderButton +import ru.tinkoff.acquiring.sdk.utils.lazyView + +class PaymentFailureInsufficientFundsDialogFragment : BottomSheetDialogFragment() { + + private val buttonBackToPayment: LoaderButton by lazyView(R.id.acq_button_back_to_payment) + private val buttonOk: LoaderButton by lazyView(R.id.acq_button_ok) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NO_FRAME, theme) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = + inflater.inflate(R.layout.acq_fragment_payment_failure_insufficient_funds, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + buttonBackToPayment.setOnClickListener { onBackToPayment() } + buttonOk.setOnClickListener { onOk() } + } + + private fun onBackToPayment() { + ((parentFragment as? OnBackToPayment) ?: (activity as? OnBackToPayment)) + ?.onPaymentFailureBackToPayment(this) + } + + private fun onOk() { + ((parentFragment as? OnOk) ?: (activity as? OnOk))?.onPaymentFailureOk(this) + } + + fun interface OnBackToPayment { + fun onPaymentFailureBackToPayment(fragment: PaymentFailureInsufficientFundsDialogFragment) + } + + fun interface OnOk { + fun onPaymentFailureOk(fragment: PaymentFailureInsufficientFundsDialogFragment) + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentSuccessDialogFragment.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentSuccessDialogFragment.kt new file mode 100644 index 00000000..0a7dbfa1 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentSuccessDialogFragment.kt @@ -0,0 +1,37 @@ +package ru.tinkoff.acquiring.sdk.redesign.dialog + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import ru.tinkoff.acquiring.sdk.R +import ru.tinkoff.acquiring.sdk.ui.customview.LoaderButton +import ru.tinkoff.acquiring.sdk.utils.lazyView + +class PaymentSuccessDialogFragment : BottomSheetDialogFragment() { + + private val buttonOk: LoaderButton by lazyView(R.id.acq_button_ok) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NO_FRAME, theme) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = + inflater.inflate(R.layout.acq_fragment_payment_success, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + buttonOk.setOnClickListener { onOk() } + } + + private fun onOk() { + ((parentFragment as? OnOk) ?: (activity as? OnOk))?.onPaymentSuccessOk(this) + } + + fun interface OnOk { + fun onPaymentSuccessOk(fragment: PaymentSuccessDialogFragment) + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/LoaderButton.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/LoaderButton.kt index 3dc75b32..32c4d523 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/LoaderButton.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/LoaderButton.kt @@ -34,7 +34,7 @@ constructor( set(value) { field = value textView.isGone = field - loader.isVisible = field + progressBar.isVisible = field } val textView = TextView(context).apply { @@ -42,25 +42,38 @@ constructor( setTextColor(ResourcesCompat.getColorStateList( context.resources, R.color.acq_button_text_selector, context.theme)) } + var textColor: ColorStateList? + get() = textView.textColors + set(value) { + textView.setTextColor(value) + } - val loader = ProgressBar(context).apply { + val progressBar = ProgressBar(context).apply { isIndeterminate = true indeterminateTintList = ColorStateList.valueOf(ResourcesCompat.getColor( context.resources, R.color.acq_colorButtonText, context.theme)) isGone = true } + var progressColor: ColorStateList? + get() = progressBar.indeterminateTintList + set(value) { + progressBar.indeterminateTintList = value + } init { addView(textView, LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { gravity = Gravity.CENTER }) - addView(loader, LayoutParams(context.dpToPx(24), context.dpToPx(24)).apply { + addView(progressBar, LayoutParams(context.dpToPx(24), context.dpToPx(24)).apply { gravity = Gravity.CENTER }) context.withStyledAttributes(attrs, R.styleable.LoaderButton, defStyleAttr, defStyleRes) { text = getString(R.styleable.LoaderButton_acq_text).orEmpty() + textColor = getColorStateList(R.styleable.LoaderButton_acq_text_color) + getDrawable(R.styleable.LoaderButton_acq_background)?.let { background = it } + ?: setBackgroundResource(R.drawable.acq_button_yellow_bg) + progressColor = getColorStateList(R.styleable.LoaderButton_acq_progress_color) } - setBackgroundResource(R.drawable.acq_button_yellow_bg) } } \ No newline at end of file diff --git a/ui/src/main/res/color/acq_secondary_button_text_selector.xml b/ui/src/main/res/color/acq_secondary_button_text_selector.xml new file mode 100644 index 00000000..17a6c88c --- /dev/null +++ b/ui/src/main/res/color/acq_secondary_button_text_selector.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/drawable/acq_bottom_sheet_bg.xml b/ui/src/main/res/drawable/acq_bottom_sheet_bg.xml new file mode 100644 index 00000000..8626ac8d --- /dev/null +++ b/ui/src/main/res/drawable/acq_bottom_sheet_bg.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/drawable/acq_button_flat_bg.xml b/ui/src/main/res/drawable/acq_button_flat_bg.xml new file mode 100644 index 00000000..4a7587a7 --- /dev/null +++ b/ui/src/main/res/drawable/acq_button_flat_bg.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/drawable/acq_button_yellow_bg_ripple.xml b/ui/src/main/res/drawable/acq_button_secondary_bg.xml similarity index 57% rename from ui/src/main/res/drawable/acq_button_yellow_bg_ripple.xml rename to ui/src/main/res/drawable/acq_button_secondary_bg.xml index b0798a8a..c2137943 100644 --- a/ui/src/main/res/drawable/acq_button_yellow_bg_ripple.xml +++ b/ui/src/main/res/drawable/acq_button_secondary_bg.xml @@ -13,15 +13,24 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> + - - - + - + - \ No newline at end of file + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/drawable/acq_button_yellow_bg.xml b/ui/src/main/res/drawable/acq_button_yellow_bg.xml index e736e007..65f17bc6 100644 --- a/ui/src/main/res/drawable/acq_button_yellow_bg.xml +++ b/ui/src/main/res/drawable/acq_button_yellow_bg.xml @@ -23,6 +23,16 @@ - + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/drawable/acq_ic_check_circle_positive.xml b/ui/src/main/res/drawable/acq_ic_check_circle_positive.xml new file mode 100644 index 00000000..eac6b95a --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_check_circle_positive.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/acq_ic_cross_circle.xml b/ui/src/main/res/drawable/acq_ic_cross_circle.xml new file mode 100644 index 00000000..2dbd692d --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_cross_circle.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + diff --git a/ui/src/main/res/layout/acq_fragment_open_bank_progress.xml b/ui/src/main/res/layout/acq_fragment_open_bank_progress.xml new file mode 100644 index 00000000..1f44a3fe --- /dev/null +++ b/ui/src/main/res/layout/acq_fragment_open_bank_progress.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_fragment_payment_failure.xml b/ui/src/main/res/layout/acq_fragment_payment_failure.xml new file mode 100644 index 00000000..828ce492 --- /dev/null +++ b/ui/src/main/res/layout/acq_fragment_payment_failure.xml @@ -0,0 +1,34 @@ + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_fragment_payment_failure_insufficient_funds.xml b/ui/src/main/res/layout/acq_fragment_payment_failure_insufficient_funds.xml new file mode 100644 index 00000000..c54b5e2f --- /dev/null +++ b/ui/src/main/res/layout/acq_fragment_payment_failure_insufficient_funds.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_fragment_payment_success.xml b/ui/src/main/res/layout/acq_fragment_payment_success.xml new file mode 100644 index 00000000..28c59e68 --- /dev/null +++ b/ui/src/main/res/layout/acq_fragment_payment_success.xml @@ -0,0 +1,34 @@ + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/values/attrs.xml b/ui/src/main/res/values/attrs.xml index 12296a20..1bb9f22f 100644 --- a/ui/src/main/res/values/attrs.xml +++ b/ui/src/main/res/values/attrs.xml @@ -1,5 +1,4 @@ - - + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_activity_card_list.xml b/ui/src/main/res/layout/acq_activity_card_list.xml index d24f476f..ffa8a88d 100644 --- a/ui/src/main/res/layout/acq_activity_card_list.xml +++ b/ui/src/main/res/layout/acq_activity_card_list.xml @@ -43,7 +43,7 @@ + layout="@layout/acq_list_stub" /> diff --git a/ui/src/main/res/layout/acq_activity_stub_sbp_no_banks.xml b/ui/src/main/res/layout/acq_activity_stub_sbp_no_banks.xml new file mode 100644 index 00000000..b0eac29d --- /dev/null +++ b/ui/src/main/res/layout/acq_activity_stub_sbp_no_banks.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_bank_list_bank_item_shimmer.xml b/ui/src/main/res/layout/acq_bank_list_bank_item_shimmer.xml new file mode 100644 index 00000000..21531641 --- /dev/null +++ b/ui/src/main/res/layout/acq_bank_list_bank_item_shimmer.xml @@ -0,0 +1,34 @@ + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_bank_list_content.xml b/ui/src/main/res/layout/acq_bank_list_content.xml new file mode 100644 index 00000000..b39c254f --- /dev/null +++ b/ui/src/main/res/layout/acq_bank_list_content.xml @@ -0,0 +1,25 @@ + + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_bank_list_item.xml b/ui/src/main/res/layout/acq_bank_list_item.xml new file mode 100644 index 00000000..d0ff5950 --- /dev/null +++ b/ui/src/main/res/layout/acq_bank_list_item.xml @@ -0,0 +1,44 @@ + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_bank_list_shimmer.xml b/ui/src/main/res/layout/acq_bank_list_shimmer.xml new file mode 100644 index 00000000..f87337d5 --- /dev/null +++ b/ui/src/main/res/layout/acq_bank_list_shimmer.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_item_card_logo.xml b/ui/src/main/res/layout/acq_item_card_logo.xml index b9a0b456..dfbd3637 100644 --- a/ui/src/main/res/layout/acq_item_card_logo.xml +++ b/ui/src/main/res/layout/acq_item_card_logo.xml @@ -4,7 +4,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:paddingStart="0dp" - android:paddingEnd="16dp" + android:paddingEnd="12dp" tools:src="@drawable/acq_ic_card_mir_tinkoff"> \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_card_list_stub.xml b/ui/src/main/res/layout/acq_list_stub.xml similarity index 92% rename from ui/src/main/res/layout/acq_card_list_stub.xml rename to ui/src/main/res/layout/acq_list_stub.xml index 9a86679d..2cc1c6d7 100644 --- a/ui/src/main/res/layout/acq_card_list_stub.xml +++ b/ui/src/main/res/layout/acq_list_stub.xml @@ -24,7 +24,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" - android:src="@drawable/acq_ic_cards_list_error_stub" + android:src="@drawable/acq_ic_generic_error_stub" app:layout_constraintBottom_toTopOf="@+id/acq_stub_title" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.5" @@ -40,7 +40,7 @@ android:layout_marginTop="24dp" android:fontFamily="@font/roboto_regular" android:gravity="center" - android:text="@string/acq_cardlist_stubnet_title" + android:text="@string/acq_generic_stubnet_title" app:layout_constraintBottom_toTopOf="@+id/acq_stub_subtitle" app:layout_constraintTop_toBottomOf="@+id/acq_stub_img" /> @@ -51,7 +51,7 @@ android:layout_height="wrap_content" android:layout_marginTop="8dp" android:gravity="center" - tools:text="@string/acq_cardlist_stubnet_description" + tools:text="@string/acq_generic_stubnet_description" app:layout_constraintBottom_toTopOf="@+id/acq_stub_retry_button" app:layout_constraintTop_toBottomOf="@+id/acq_stub_title" /> @@ -64,7 +64,7 @@ android:gravity="center" android:paddingHorizontal="18dp" android:paddingVertical="14dp" - android:text="@string/acq_cardlist_button_stubnet" + android:text="@string/acq_generic_button_stubnet" android:textAllCaps="false" android:textColor="@color/acq_colorAccent" android:textSize="@dimen/acq_small_text_size" diff --git a/ui/src/main/res/values-ru/strings.xml b/ui/src/main/res/values-ru/strings.xml index c63534c0..9bf980c1 100644 --- a/ui/src/main/res/values-ru/strings.xml +++ b/ui/src/main/res/values-ru/strings.xml @@ -27,13 +27,13 @@ %1$s • %2$s Ваши карты - - Попробуйте снова через пару минут - Понятно + + Попробуйте снова через пару минут + Понятно - Не загрузилось - - Обновить + Не загрузилось + + Обновить Здесь будут ваши карты Добавить Добавить новую @@ -73,4 +73,8 @@ Сбербанк Райффайзен + Выбор банка + + Узнать подробности + \ No newline at end of file diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 96d1f8a3..278d4257 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -35,13 +35,13 @@ Code Attach - - Try again in a couple of minutes - Clear + + Try again in a couple of minutes + Clear - Not loaded - - Refresh + Not loaded + + Refresh This is where your cards will be Add @@ -71,4 +71,8 @@ Raiffeizen Gazprombank + Choose bank + You need a bank app wit Fast Payment System support to make the payment + Details + From 631c5cf21bceab7f97bb94af7a7b923dec120e49 Mon Sep 17 00:00:00 2001 From: jqwout Date: Mon, 9 Jan 2023 14:14:14 +0300 Subject: [PATCH 022/126] =?UTF-8?q?MC-7093=20-=20=D0=B1=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20=D1=8D=D0=BA=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0,=20=D0=BF=D0=BE=D0=BA=D0=B0=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D0=B8=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../list/presentation/CardsListViewModel.kt | 8 +- .../cards/list/ui/CardsListActivity.kt | 79 +++++++++++-------- .../redesign/cards/list/ui/CardsListState.kt | 2 + .../acquiring/sdk/utils/AcqSnackBarHelper.kt | 2 +- 4 files changed, 58 insertions(+), 33 deletions(-) diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt index 68ee182b..843fc134 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt @@ -63,6 +63,7 @@ internal class CardsListViewModel( return } + eventFlow.value = CardListEvent.RemoveCardProgress deleteJob = manager.launchOnBackground { if (connectionChecker.isOnline().not()) { eventFlow.value = CardListEvent.ShowError @@ -77,7 +78,6 @@ internal class CardsListViewModel( this.customerKey = customerKey } .executeFlow() - .onStart { eventFlow.value = CardListEvent.RemoveCardProgress } .collect { it.process( onSuccess = { @@ -109,6 +109,12 @@ internal class CardsListViewModel( } } + fun onBackPressed() { + if(eventFlow.value !is CardListEvent.RemoveCardProgress) { + eventFlow.value = CardListEvent.CloseScreen + } + } + private fun handleGetCardListResponse(it: GetCardListResponse, recurrentOnly: Boolean) { try { val uiCards = filterCards(it.cards, recurrentOnly) diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt index c4a72fc6..8f9d5970 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt @@ -3,10 +3,7 @@ package ru.tinkoff.acquiring.sdk.redesign.cards.list.ui import android.app.Activity import android.content.Intent import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.ViewGroup -import android.view.View +import android.view.* import android.widget.ImageView import android.widget.TextView import android.widget.ViewFlipper @@ -28,29 +25,31 @@ import ru.tinkoff.acquiring.sdk.redesign.cards.list.models.CardItemUiModel import ru.tinkoff.acquiring.sdk.redesign.cards.list.presentation.CardsListViewModel import ru.tinkoff.acquiring.sdk.redesign.common.util.AcqShimmerAnimator import ru.tinkoff.acquiring.sdk.ui.activities.TransparentActivity -import ru.tinkoff.acquiring.sdk.utils.AcqSnackBarHelper -import ru.tinkoff.acquiring.sdk.utils.ErrorResolver +import ru.tinkoff.acquiring.sdk.utils.* +import ru.tinkoff.acquiring.sdk.utils.lazyUnsafe import ru.tinkoff.acquiring.sdk.utils.lazyView -import ru.tinkoff.acquiring.sdk.utils.showById internal class CardsListActivity : TransparentActivity() { private lateinit var viewModel: CardsListViewModel private lateinit var savedCardsOptions: SavedCardsOptions - private lateinit var recyclerView: RecyclerView - private lateinit var cardsListAdapter: CardsListAdapter - private lateinit var viewFlipper: ViewFlipper - private lateinit var cardShimmer: ViewGroup - private lateinit var snackBarHelper: AcqSnackBarHelper - private var mode = CardListMode.STUB + private val recyclerView: RecyclerView by lazyView(R.id.acq_card_list_view) + private val viewFlipper: ViewFlipper by lazyView(R.id.acq_view_flipper) + private val cardShimmer: ViewGroup by lazyView(R.id.acq_card_list_shimmer) + private val root: ViewGroup by lazyView(R.id.acq_card_list_root) private val stubImage: ImageView by lazyView(R.id.acq_stub_img) private val stubTitleView: TextView by lazyView(R.id.acq_stub_title) private val stubSubtitleView: TextView by lazyView(R.id.acq_stub_subtitle) private val stubButtonView: TextView by lazyView(R.id.acq_stub_retry_button) private val addNewCard: TextView by lazyView(R.id.acq_add_new_card) + private lateinit var cardsListAdapter: CardsListAdapter + + private val snackBarHelper: AcqSnackBarHelper by lazyUnsafe { + AcqSnackBarHelper(root) + } private val attachCard = registerForActivityResult(AttachCard.Contract) { result -> when (result) { @@ -59,11 +58,15 @@ internal class CardsListActivity : TransparentActivity() { viewModel.loadData( savedCardsOptions.customer.customerKey, - options.features.showOnlyRecurrentCards) + options.features.showOnlyRecurrentCards + ) } is AttachCard.Error -> showErrorDialog( getString(R.string.acq_generic_alert_label), - ErrorResolver.resolve(result.error, getString(R.string.acq_generic_stub_description)), + ErrorResolver.resolve( + result.error, + getString(R.string.acq_generic_stub_description) + ), getString(R.string.acq_generic_alert_access) ) else -> Unit @@ -116,7 +119,7 @@ internal class CardsListActivity : TransparentActivity() { } override fun onBackPressed() { - finish() + viewModel.onBackPressed() } private fun initToolbar() { @@ -127,15 +130,11 @@ internal class CardsListActivity : TransparentActivity() { } private fun initViews() { - recyclerView = findViewById(R.id.acq_card_list_view) - viewFlipper = findViewById(R.id.acq_view_flipper) - cardShimmer = viewFlipper.findViewById(R.id.acq_card_list_shimmer) cardsListAdapter = CardsListAdapter(onDeleteClick = { viewModel.deleteCard(it, savedCardsOptions.customer.customerKey!!) }) recyclerView.adapter = cardsListAdapter addNewCard.setOnClickListener { startAttachCard() } - snackBarHelper = AcqSnackBarHelper(findViewById(R.id.acq_card_list_root)) } private fun subscribeOnState() { @@ -221,30 +220,34 @@ internal class CardsListActivity : TransparentActivity() { private fun CardItemUiModel.handleCardAttached() { attachedCardId = null - snackBarHelper.showWithIcon(R.drawable.acq_ic_card_sparkle, - getString(R.string.acq_cardlist_snackbar_add, tail)) + snackBarHelper.showWithIcon( + R.drawable.acq_ic_card_sparkle, + getString(R.string.acq_cardlist_snackbar_add, tail) + ) } private fun CoroutineScope.subscribeOnEvents() { launch { viewModel.eventFlow.filterNotNull().collect { - recyclerView.alpha = if (it is CardListEvent.RemoveCardProgress) 0.5f else 1f - recyclerView.isEnabled = it !is CardListEvent.RemoveCardProgress - + handleDeleteInProgress(it is CardListEvent.RemoveCardProgress) when (it) { - is CardListEvent.RemoveCardProgress -> { - snackBarHelper.showProgress(R.string.acq_cardlist_snackbar_remove_progress) - } + is CardListEvent.RemoveCardProgress -> Unit is CardListEvent.RemoveCardSuccess -> { it.indexAt?.let(cardsListAdapter::onRemoveCard) - snackBarHelper.showWithIcon(R.drawable.acq_ic_card_sparkle, - getString(R.string.acq_cardlist_snackbar_remove, it.deletedCard.tail)) + snackBarHelper.showWithIcon( + R.drawable.acq_ic_card_sparkle, + getString(R.string.acq_cardlist_snackbar_remove, it.deletedCard.tail) + ) } is CardListEvent.ShowError -> { showErrorDialog( R.string.acq_generic_alert_label, R.string.acq_generic_stub_description, - R.string.acq_generic_alert_access) + R.string.acq_generic_alert_access + ) + } + is CardListEvent.CloseScreen -> { + finish() } } } @@ -284,4 +287,18 @@ internal class CardsListActivity : TransparentActivity() { } super.finish() } + + private fun handleDeleteInProgress(inProgress: Boolean) { + root.alpha = if (inProgress) 0.5f else 1f + if (inProgress) { + window.setFlags( + WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE, + WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE + ) + snackBarHelper.showProgress(R.string.acq_cardlist_snackbar_remove_progress) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE) + snackBarHelper.hide() + } + } } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt index bcdfd80e..d92b6f5e 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt @@ -26,6 +26,8 @@ sealed class CardListEvent { val indexAt: Int?) : CardListEvent() object ShowError : CardListEvent() + + object CloseScreen : CardListEvent() } enum class CardListMode { diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBarHelper.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBarHelper.kt index 374cf3ea..04b1261b 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBarHelper.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBarHelper.kt @@ -22,7 +22,7 @@ class AcqSnackBarHelper(private val view: View) { fun showProgress(textValue: String) { snackbar?.takeIf { it.isShown }?.dismiss() - val bar = Snackbar.make(view, "", Snackbar.LENGTH_SHORT).apply { snackbar = this } + val bar = Snackbar.make(view, "", Snackbar.LENGTH_LONG).apply { snackbar = this } val customSnackView: View = LayoutInflater.from(view.context).inflate(R.layout.acq_snackbar_progress_layout, null) val textView = customSnackView.findViewById(R.id.acq_snackbar_text) From 65438e98e13dbac2c96c9ef4d6d5e045ea95f216 Mon Sep 17 00:00:00 2001 From: jqwout Date: Mon, 9 Jan 2023 15:19:19 +0300 Subject: [PATCH 023/126] Merge tag 'v2.12' into v3.0.0 --- gradle/versions.gradle | 2 +- ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt | 4 +--- .../acquiring/sdk/ui/activities/YandexPaymentActivity.kt | 2 +- .../acquiring/sdk/ui/customview/editcard/CardPaymentSystem.kt | 2 +- .../tinkoff/acquiring/sdk/ui/customview/editcard/EditCard.kt | 2 +- yandexpay/build.gradle | 2 +- 6 files changed, 6 insertions(+), 8 deletions(-) diff --git a/gradle/versions.gradle b/gradle/versions.gradle index de59cd19..da93b454 100644 --- a/gradle/versions.gradle +++ b/gradle/versions.gradle @@ -18,7 +18,7 @@ ext { gsonVersion = '2.8.6' coreNfcVersion = '1.0.3' decoroVersion = '1.5.1' - coroutinesVersion = '1.3.7' + coroutinesVersion = '1.6.4' googleWalletVersion = '18.0.0' constraintLayoutVersion = '1.1.3' diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt index 35e4c301..f320f936 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt @@ -37,15 +37,13 @@ import ru.tinkoff.acquiring.sdk.responses.GetTerminalPayMethodsResponse import ru.tinkoff.acquiring.sdk.responses.TerminalInfo import ru.tinkoff.acquiring.sdk.redesign.cards.list.ui.CardsListActivity import ru.tinkoff.acquiring.sdk.responses.TinkoffPayStatusResponse -import ru.tinkoff.acquiring.sdk.threeds.ThreeDsHelper import ru.tinkoff.acquiring.sdk.ui.activities.* import ru.tinkoff.acquiring.sdk.ui.activities.AttachCardActivity import ru.tinkoff.acquiring.sdk.ui.activities.BaseAcquiringActivity import ru.tinkoff.acquiring.sdk.ui.activities.NotificationPaymentActivity import ru.tinkoff.acquiring.sdk.ui.activities.PaymentActivity import ru.tinkoff.acquiring.sdk.ui.activities.QrCodeActivity -import ru.tinkoff.acquiring.sdk.ui.activities.SavedCardsActivity -import kotlin.coroutines.suspendCoroutine +import ru.tinkoff.acquiring.sdk.ui.activities.YandexPaymentActivity /** * Точка входа для взаимодействия с Acquiring SDK diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/YandexPaymentActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/YandexPaymentActivity.kt index e85d6738..3d10c0e8 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/YandexPaymentActivity.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/activities/YandexPaymentActivity.kt @@ -46,7 +46,7 @@ internal class YandexPaymentActivity : TransparentActivity() { asdkState = paymentOptions.asdkState initViews() - bottomContainer.isVisible = false + bottomContainer?.isVisible = false paymentViewModel = provideViewModel(YandexPaymentViewModel::class.java) as YandexPaymentViewModel diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/CardPaymentSystem.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/CardPaymentSystem.kt index a1443206..58efeae8 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/CardPaymentSystem.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/CardPaymentSystem.kt @@ -44,7 +44,7 @@ enum class CardPaymentSystem(val regex: Regex, val range: IntRange, val showLogo companion object { - fun resolvePaymentSystem(cardNumber: String): CardPaymentSystem { + fun resolve(cardNumber: String): CardPaymentSystem { if (cardNumber.length == 1 && cardNumber.startsWith("6")) { // special case: wait for more digits for MAESTRO/UNION_PAY disambiguation return UNKNOWN diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/EditCard.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/EditCard.kt index d7f3a42c..a241db82 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/EditCard.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/customview/editcard/EditCard.kt @@ -925,7 +925,7 @@ internal class EditCard @JvmOverloads constructor( } private fun shouldAutoSwitchFromCardNumber(): Boolean { - val paymentSystem = CardPaymentSystem.resolvePaymentSystem(cardNumber) + val paymentSystem = CardPaymentSystem.resolve(cardNumber) return cardNumber.length == paymentSystem.range.last } diff --git a/yandexpay/build.gradle b/yandexpay/build.gradle index 72eb50c8..f7203157 100644 --- a/yandexpay/build.gradle +++ b/yandexpay/build.gradle @@ -48,6 +48,6 @@ dependencies { testImplementation 'junit:junit:4.13' - testImplementation "org.mockito.kotlin:mockito-kotlin:${mokitoKotlin}" + testImplementation "org.mockito.kotlin:mockito-kotlin:${mokitoKotlinVersion}" testImplementation 'org.mockito:mockito-inline:2.13.0' } From cf87e3cc439cbeb0784c7dea5db739b4561f737b Mon Sep 17 00:00:00 2001 From: jqwout Date: Mon, 9 Jan 2023 18:05:26 +0300 Subject: [PATCH 024/126] =?UTF-8?q?MC-7683=20=D0=B2=D0=BE=D0=B7=D0=BC?= =?UTF-8?q?=D0=BE=D0=B6=D0=BD=D0=BE=D1=82=D1=8C=20=D0=B4=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?=D0=BC=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=20=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20=D0=BE=D0=BA=D1=80=D1=83=D0=B6=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B2=20=D1=81=D0=B5=D0=BC=D0=BF=D0=BB=D0=B5=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ru/tinkoff/acquiring/sdk/AcquiringSdk.kt | 6 ++ .../acquiring/sdk/network/AcquiringApi.kt | 26 +++++++- .../sample/ui/AcqEnvironmentDialog.kt | 65 +++++++++++++++++++ .../acquiring/sample/ui/MainActivity.kt | 4 ++ .../src/main/res/layout/dialog_asdk_env.xml | 45 +++++++++++++ sample/src/main/res/menu/main_menu.xml | 5 ++ sample/src/main/res/values-ru/strings.xml | 1 + sample/src/main/res/values/strings.xml | 1 + .../acquiring/sdk/utils/ViewExtUtil.kt | 1 + 9 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 sample/src/main/java/ru/tinkoff/acquiring/sample/ui/AcqEnvironmentDialog.kt create mode 100644 sample/src/main/res/layout/dialog_asdk_env.xml diff --git a/core/src/main/java/ru/tinkoff/acquiring/sdk/AcquiringSdk.kt b/core/src/main/java/ru/tinkoff/acquiring/sdk/AcquiringSdk.kt index ab9bfb6b..377dc980 100644 --- a/core/src/main/java/ru/tinkoff/acquiring/sdk/AcquiringSdk.kt +++ b/core/src/main/java/ru/tinkoff/acquiring/sdk/AcquiringSdk.kt @@ -284,6 +284,12 @@ class AcquiringSdk( */ var isDeveloperMode = false + /** + * Позволяет переключать SDK с тестового режима(на другой контур) и обратно. В тестовом режиме деньги с карты не + * списываются. По-умолчанию выключен + */ + var isPreprodMode = false + /** * Логирует сообщение diff --git a/core/src/main/java/ru/tinkoff/acquiring/sdk/network/AcquiringApi.kt b/core/src/main/java/ru/tinkoff/acquiring/sdk/network/AcquiringApi.kt index d3cb081c..c060a0ad 100644 --- a/core/src/main/java/ru/tinkoff/acquiring/sdk/network/AcquiringApi.kt +++ b/core/src/main/java/ru/tinkoff/acquiring/sdk/network/AcquiringApi.kt @@ -86,6 +86,9 @@ object AcquiringApi { private const val API_URL_RELEASE = "https://securepay.tinkoff.ru/$API_VERSION" private const val API_URL_DEBUG = "https://rest-api-test.tinkoff.ru/$API_VERSION" + private const val API_URL_PREPROD_OLD = "https://qa-mapi.tcsbank.ru/rest" + private const val API_URL_PREPROD = "https://qa-mapi.tcsbank.ru/$API_VERSION" + private val oldMethodsList = listOf("Submit3DSAuthorization") /** @@ -93,10 +96,29 @@ object AcquiringApi { * Зависит от режима работы SDK [AcquiringSdk.isDeveloperMode] */ fun getUrl(apiMethod: String): String { + return if (useV1Api(apiMethod)) { - if (AcquiringSdk.isDeveloperMode) API_URL_DEBUG_OLD else API_URL_RELEASE_OLD + if (AcquiringSdk.isDeveloperMode) { + + if (AcquiringSdk.isPreprodMode) + API_URL_PREPROD_OLD + else + API_URL_DEBUG_OLD + + } else { + API_URL_RELEASE_OLD + } } else { - if (AcquiringSdk.isDeveloperMode) API_URL_DEBUG else API_URL_RELEASE + if (AcquiringSdk.isDeveloperMode) { + + if (AcquiringSdk.isPreprodMode) + API_URL_PREPROD + else + API_URL_DEBUG + + } else { + API_URL_RELEASE + } } } diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/AcqEnvironmentDialog.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/AcqEnvironmentDialog.kt new file mode 100644 index 00000000..19490a69 --- /dev/null +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/AcqEnvironmentDialog.kt @@ -0,0 +1,65 @@ +package ru.tinkoff.acquiring.sample.ui + +import android.graphics.Point +import android.os.Bundle +import android.view.* +import android.widget.LinearLayout +import android.widget.Switch +import android.widget.TextView +import androidx.fragment.app.DialogFragment +import ru.tinkoff.acquiring.sample.R +import ru.tinkoff.acquiring.sdk.AcquiringSdk +import ru.tinkoff.acquiring.sdk.network.AcquiringApi + + +class AcqEnvironmentDialog : DialogFragment() { + + private val description: TextView by lazy { + requireView().findViewById(R.id.acq_env_description) + } + private val ok: TextView by lazy { + requireView().findViewById(R.id.acq_env_ok) + } + private val isPreProdSwitcher: Switch by lazy { + requireView().findViewById(R.id.acq_env_is_pre_prod) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.dialog_asdk_env, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + isPreProdSwitcher.setOnCheckedChangeListener { _, isPreprod -> + setEnv(isPreprod) + } + + setEnv(AcquiringSdk.isPreprodMode) + isPreProdSwitcher.isChecked = AcquiringSdk.isPreprodMode + + ok.setOnClickListener { dismiss() } + } + + override fun onResume() { + dialog?.window?.setLayout(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) + super.onResume() + } + + private fun getDescriptionText(): String { + return "Для запросов на Back-end Acquiring будет использован URL: ${AcquiringApi.getUrl("")} " + } + + private fun setEnv(isPreprod: Boolean) { + AcquiringSdk.isPreprodMode = isPreprod + description.text = getDescriptionText() + } + + companion object { + const val TAG = "AcqEnvironmentDialog" + } +} \ No newline at end of file diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt index 876088ea..9b951dc5 100644 --- a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt @@ -170,6 +170,10 @@ class MainActivity : AppCompatActivity(), BooksListAdapter.BookDetailsClickListe SettingsActivity.start(this) true } + R.id.menu_action_environment -> { + AcqEnvironmentDialog().show(supportFragmentManager, AcqEnvironmentDialog.TAG) + true + } else -> super.onOptionsItemSelected(item) } } diff --git a/sample/src/main/res/layout/dialog_asdk_env.xml b/sample/src/main/res/layout/dialog_asdk_env.xml new file mode 100644 index 00000000..13ef7537 --- /dev/null +++ b/sample/src/main/res/layout/dialog_asdk_env.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/sample/src/main/res/menu/main_menu.xml b/sample/src/main/res/menu/main_menu.xml index 85352c55..7b4e6d4c 100644 --- a/sample/src/main/res/menu/main_menu.xml +++ b/sample/src/main/res/menu/main_menu.xml @@ -70,4 +70,9 @@ android:title="@string/activity_title_about" app:showAsAction="never"/> + + \ No newline at end of file diff --git a/sample/src/main/res/values-ru/strings.xml b/sample/src/main/res/values-ru/strings.xml index 00358b82..e5759b56 100644 --- a/sample/src/main/res/values-ru/strings.xml +++ b/sample/src/main/res/values-ru/strings.xml @@ -31,6 +31,7 @@ Сохраненные карты Отправить уведомление Подписка на книги + Окружение Автор Год выпуска diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml index b2e9a46e..eb5af723 100644 --- a/sample/src/main/res/values/strings.xml +++ b/sample/src/main/res/values/strings.xml @@ -30,6 +30,7 @@ Saved cards Send notification Book Subscription + Environment Author Year diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/ViewExtUtil.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/ViewExtUtil.kt index 623e0014..07200e93 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/ViewExtUtil.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/ViewExtUtil.kt @@ -10,6 +10,7 @@ import android.view.View import android.view.ViewGroup import android.view.ViewParent import androidx.annotation.IdRes +import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import kotlin.math.roundToInt import kotlin.math.roundToLong From 970dabe0998ecdfbeed37192585aac1354926063 Mon Sep 17 00:00:00 2001 From: jqwout Date: Tue, 10 Jan 2023 11:55:19 +0300 Subject: [PATCH 025/126] =?UTF-8?q?MC-7875=20-=20=D0=BD=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=B1=D0=B8=D0=BD=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D1=82?= =?UTF-8?q?=D0=B8=D0=BD=D1=8C=D0=BA=D0=BE=D1=84=D1=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/BankIssuer.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/BankIssuer.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/BankIssuer.kt index 4ac30481..f2252ec9 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/BankIssuer.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/BankIssuer.kt @@ -218,6 +218,8 @@ private val TINKOFF_BINS = setOf( "551960", "553420", "553691", + "626301", + "626429", ) private val RAIFFEISEN_BINS = setOf( From 1856d3e3d717b60d3a649966f8ba0e8572611a80 Mon Sep 17 00:00:00 2001 From: jqwout Date: Tue, 24 Jan 2023 16:26:32 +0300 Subject: [PATCH 026/126] =?UTF-8?q?MC-7997=20=D0=BE=D0=BF=D0=BB=D0=B0?= =?UTF-8?q?=D1=82=D0=B0=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20=D0=A1=D0=91?= =?UTF-8?q?=D0=9F=20=D0=B8=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=D1=8B=20?= =?UTF-8?q?=D0=B2=20=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ru/tinkoff/acquiring/sdk/AcquiringSdk.kt | 2 + .../AcquiringSdkTimeoutException.kt | 24 ++ .../acquiring/sdk/models/paysources/SbpPay.kt | 11 + .../acquiring/sdk/network/AcquiringApi.kt | 2 +- .../acquiring/sample/ui/MainActivity.kt | 18 +- .../acquiring/sample/ui/PayableActivity.kt | 27 ++- .../acquiring/sample/utils/SessionParams.kt | 2 + .../sample/utils/TerminalsManager.kt | 9 +- ui/build.gradle | 1 + ui/src/main/AndroidManifest.xml | 2 +- .../tinkoff/acquiring/sdk/TinkoffAcquiring.kt | 1 - .../acquiring/sdk/models/NspkRequest.kt | 26 ++- .../acquiring/sdk/payment/PaymentState.kt | 7 +- .../sdk/payment/SbpPaymentProcess.kt | 197 +++++++++++++++++ .../sdk/payment/YandexPaymentProcess.kt | 2 +- ...tFailureInsufficientFundsDialogFragment.kt | 48 ---- .../redesign/dialog/PaymentStatusFormExt.kt | 47 ++++ .../sdk/redesign/dialog/PaymentStatusSheet.kt | 133 +++++++++++ .../sdk/redesign/sbp/ui/BankListViewModel.kt | 57 ----- ...kListActivity.kt => SbpPaymentActivity.kt} | 209 +++++++++++------- .../redesign/sbp/ui/SbpPaymentViewModel.kt | 67 ++++++ .../sdk/redesign/sbp/util/NspkBankProvider.kt | 5 + .../sdk/redesign/sbp/util/SbpBankChecker.kt | 10 + .../sdk/redesign/sbp/util/SbpHelper.kt | 4 - .../sdk/redesign/sbp/util/SbpStateMapper.kt | 70 ++++++ .../acquiring/sdk/utils/CoroutineManager.kt | 64 ++++-- .../ru/tinkoff/acquiring/sdk/utils/FlowExt.kt | 11 + .../tinkoff/acquiring/sdk/utils/NspkClient.kt | 57 ++--- .../main/res/drawable/acq_button_flat_bg.xml | 3 +- ..._funds.xml => acq_payment_status_form.xml} | 30 ++- ui/src/main/res/values-ru/strings.xml | 11 +- ui/src/main/res/values/strings.xml | 10 + ui/src/main/res/values/styles.xml | 7 + ui/src/test/java/common/AssertExt.kt | 4 +- ui/src/test/java/sbp/SbpTestEnvironment.kt | 102 +++++++++ ui/src/test/java/sbp/ShowBanksApps.kt | 28 +++ 36 files changed, 1038 insertions(+), 270 deletions(-) create mode 100644 core/src/main/java/ru/tinkoff/acquiring/sdk/exceptions/AcquiringSdkTimeoutException.kt create mode 100644 core/src/main/java/ru/tinkoff/acquiring/sdk/models/paysources/SbpPay.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/SbpPaymentProcess.kt delete mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentFailureInsufficientFundsDialogFragment.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusFormExt.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusSheet.kt delete mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/BankListViewModel.kt rename ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/{BankListActivity.kt => SbpPaymentActivity.kt} (50%) create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentViewModel.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/NspkBankProvider.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpBankChecker.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpStateMapper.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/FlowExt.kt rename ui/src/main/res/layout/{acq_fragment_payment_failure_insufficient_funds.xml => acq_payment_status_form.xml} (58%) create mode 100644 ui/src/test/java/sbp/SbpTestEnvironment.kt create mode 100644 ui/src/test/java/sbp/ShowBanksApps.kt diff --git a/core/src/main/java/ru/tinkoff/acquiring/sdk/AcquiringSdk.kt b/core/src/main/java/ru/tinkoff/acquiring/sdk/AcquiringSdk.kt index 377dc980..0a50065d 100644 --- a/core/src/main/java/ru/tinkoff/acquiring/sdk/AcquiringSdk.kt +++ b/core/src/main/java/ru/tinkoff/acquiring/sdk/AcquiringSdk.kt @@ -141,6 +141,8 @@ class AcquiringSdk( */ fun getQr(request: GetQrRequest.() -> Unit): GetQrRequest { return GetQrRequest().apply(request).apply { + println("Tinkoff Acquiring SDK: === $this") + println("Tinkoff Acquiring SDK: === ${this@AcquiringSdk.terminalKey}") terminalKey = this@AcquiringSdk.terminalKey } } diff --git a/core/src/main/java/ru/tinkoff/acquiring/sdk/exceptions/AcquiringSdkTimeoutException.kt b/core/src/main/java/ru/tinkoff/acquiring/sdk/exceptions/AcquiringSdkTimeoutException.kt new file mode 100644 index 00000000..16bfae35 --- /dev/null +++ b/core/src/main/java/ru/tinkoff/acquiring/sdk/exceptions/AcquiringSdkTimeoutException.kt @@ -0,0 +1,24 @@ +/* + * Copyright © 2020 Tinkoff Bank + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ru.tinkoff.acquiring.sdk.exceptions + +/** + * Исключение, выбрасываемое в случае, когда ожидание платежа истекло + * + * @author i.golovachev + */ +class AcquiringSdkTimeoutException(throwable: Throwable) : RuntimeException(throwable.message, throwable) \ No newline at end of file diff --git a/core/src/main/java/ru/tinkoff/acquiring/sdk/models/paysources/SbpPay.kt b/core/src/main/java/ru/tinkoff/acquiring/sdk/models/paysources/SbpPay.kt new file mode 100644 index 00000000..fbfac499 --- /dev/null +++ b/core/src/main/java/ru/tinkoff/acquiring/sdk/models/paysources/SbpPay.kt @@ -0,0 +1,11 @@ +package ru.tinkoff.acquiring.sdk.models.paysources + +import ru.tinkoff.acquiring.sdk.models.PaymentSource + +/** + * Тип оплаты с помощью SBP Pay + * + * + * Created by i.golovachev + */ +object SbpPay : PaymentSource \ No newline at end of file diff --git a/core/src/main/java/ru/tinkoff/acquiring/sdk/network/AcquiringApi.kt b/core/src/main/java/ru/tinkoff/acquiring/sdk/network/AcquiringApi.kt index c060a0ad..2778147a 100644 --- a/core/src/main/java/ru/tinkoff/acquiring/sdk/network/AcquiringApi.kt +++ b/core/src/main/java/ru/tinkoff/acquiring/sdk/network/AcquiringApi.kt @@ -75,7 +75,7 @@ object AcquiringApi { internal const val API_REQUEST_METHOD_POST = "POST" internal const val API_REQUEST_METHOD_GET = "GET" - internal const val JSON = "application/json" + const val JSON = "application/json" internal const val FORM_URL_ENCODED = "application/x-www-form-urlencoded" internal const val TIMEOUT = 40000L diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt index 9b951dc5..497d27d3 100644 --- a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt @@ -44,8 +44,8 @@ import ru.tinkoff.acquiring.sdk.localization.AsdkSource import ru.tinkoff.acquiring.sdk.localization.Language import ru.tinkoff.acquiring.sdk.models.options.FeaturesOptions import ru.tinkoff.acquiring.sdk.models.result.CardResult -import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.BankList import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.SbpNoBanksStubActivity +import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.SbpResult import ru.tinkoff.acquiring.sdk.redesign.sbp.util.SbpHelper import ru.tinkoff.acquiring.sdk.threeds.ThreeDsHelper @@ -76,14 +76,14 @@ class MainActivity : AppCompatActivity(), BooksListAdapter.BookDetailsClickListe } } - private val chooseBank = registerForActivityResult(BankList.Contract) { result -> + private val chooseBank = registerForActivityResult(SbpResult.Contract) { result -> when (result) { - is BankList.Success -> { - SbpHelper.openSbpDeeplink(result.deeplink, result.packageName, this) + is SbpResult.Success -> { + toast("успешная оплата") } - is BankList.Error -> toast(result.error.message ?: getString(R.string.error_title)) - is BankList.NoBanks -> SbpNoBanksStubActivity.show(this) - is BankList.Canceled -> toast("SBP canceled") + is SbpResult.Error -> toast(result.error.message ?: getString(R.string.error_title)) + is SbpResult.NoBanks -> SbpNoBanksStubActivity.show(this) + is SbpResult.Canceled -> toast("SBP canceled") } } @@ -149,10 +149,6 @@ class MainActivity : AppCompatActivity(), BooksListAdapter.BookDetailsClickListe startActivity(Intent(this, TerminalsActivity::class.java)) true } - R.id.sbp_bank_list -> { - chooseBank.launch("https://qr.nspk.ru/test_link") - true - } R.id.menu_action_about -> { AboutActivity.start(this) true diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt index bb9094d5..7112b448 100644 --- a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt @@ -29,6 +29,7 @@ import androidx.core.view.isVisible import androidx.fragment.app.commit import ru.tinkoff.acquiring.sample.R import ru.tinkoff.acquiring.sample.SampleApplication +import ru.tinkoff.acquiring.sample.ui.MainActivity.Companion.toast import ru.tinkoff.acquiring.sample.utils.SessionParams import ru.tinkoff.acquiring.sample.utils.SettingsSdkManager import ru.tinkoff.acquiring.sample.utils.TerminalsManager @@ -39,9 +40,14 @@ import ru.tinkoff.acquiring.sdk.localization.Language import ru.tinkoff.acquiring.sdk.models.AsdkState import ru.tinkoff.acquiring.sdk.models.GooglePayParams import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions +import ru.tinkoff.acquiring.sdk.models.paysources.SbpPay import ru.tinkoff.acquiring.sdk.payment.PaymentListener import ru.tinkoff.acquiring.sdk.payment.PaymentListenerAdapter import ru.tinkoff.acquiring.sdk.payment.PaymentState +import ru.tinkoff.acquiring.sdk.payment.SbpPaymentProcess +import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.SbpNoBanksStubActivity +import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.SbpResult +import ru.tinkoff.acquiring.sdk.redesign.sbp.util.SbpHelper import ru.tinkoff.acquiring.sdk.utils.GooglePayHelper import ru.tinkoff.acquiring.sdk.utils.Money import ru.tinkoff.acquiring.yandexpay.YandexButtonFragment @@ -74,6 +80,19 @@ open class PayableActivity : AppCompatActivity() { get() = abs(Random().nextInt()).toString() private var acqFragment: YandexButtonFragment? = null + + private val spbPayment = registerForActivityResult(SbpResult.Contract) { result -> + when (result) { + is SbpResult.Success -> { + toast("SBP Success") + } + is SbpResult.Error -> toast(result.error.message ?: getString(R.string.error_title)) + is SbpResult.NoBanks -> SbpNoBanksStubActivity.show(this) + is SbpResult.Canceled -> toast("SBP canceled") + } + } + + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -145,7 +164,13 @@ open class PayableActivity : AppCompatActivity() { } protected fun startSbpPayment() { - tinkoffAcquiring.payWithSbp(this, createPaymentOptions(), PAYMENT_REQUEST_CODE) + SbpPaymentProcess.init(SampleApplication.tinkoffAcquiring.sdk, packageManager) + spbPayment.launch(createPaymentOptions().apply { + this.setTerminalParams( + terminalKey = TerminalsManager.selectedTerminal.terminalKey, + publicKey = TerminalsManager.selectedTerminal.publicKey + ) + }) } protected fun setupTinkoffPay() { diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/SessionParams.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/SessionParams.kt index 8aa1a70a..a9462338 100644 --- a/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/SessionParams.kt +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/SessionParams.kt @@ -63,6 +63,8 @@ data class SessionParams( SessionParams("1578942570730", PASSWORD, PUBLIC_KEY, DEFAULT_CUSTOMER_KEY, DEFAULT_CUSTOMER_EMAIL, "SBP 3"), SessionParams("1661351612593", "45tnvz0kkyyz82mw", PUBLIC_KEY, DEFAULT_CUSTOMER_KEY, DEFAULT_CUSTOMER_EMAIL, "With token"), SessionParams("1661161705205", null, PUBLIC_KEY, DEFAULT_CUSTOMER_KEY, DEFAULT_CUSTOMER_EMAIL, "Without token"), + SessionParams("1584440932619", "dniplpm7ct3tg9e3", PUBLIC_KEY, DEFAULT_CUSTOMER_KEY, DEFAULT_CUSTOMER_EMAIL, "Sbp pay with token"), + SessionParams("1674123391307", "rpcmn7osqle5sj2r", PUBLIC_KEY, DEFAULT_CUSTOMER_KEY, DEFAULT_CUSTOMER_EMAIL, "new merchant") ) } } diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/TerminalsManager.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/TerminalsManager.kt index 8100e513..eb183741 100644 --- a/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/TerminalsManager.kt +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/TerminalsManager.kt @@ -25,7 +25,7 @@ object TerminalsManager { value.find { it.terminalKey == SessionParams.TEST_SDK.terminalKey } != null -> value else -> value.toMutableList().apply { add(0, SessionParams.TEST_SDK) } } - prefs.edit().putString(TERMINALS_KEY, gson.toJson(_terminals)).apply() + prefs.edit().putString(TERMINALS_KEY, gson.toJson(_terminals)).commit() if (terminals.find { it.terminalKey == _selectedTerminalKey } == null) { selectedTerminalKey = terminals.first().terminalKey @@ -37,7 +37,7 @@ object TerminalsManager { get() = _selectedTerminalKey!! set(value) { _selectedTerminalKey = value - prefs.edit().putString(SELECTED_TERMINAL_KEY, value).apply() + prefs.edit().putString(SELECTED_TERMINAL_KEY, value).commit() SampleApplication.initSdk(context, selectedTerminal) } @@ -65,7 +65,10 @@ object TerminalsManager { selectedTerminalKey = terminals.first().terminalKey } - fun findTerminal(terminalKey: String) = terminals.find { it.terminalKey == terminalKey } + fun findTerminal(terminalKey: String): SessionParams? { + val terminal = terminals.find { it.terminalKey == terminalKey } + return terminal + } fun requireTerminal(terminalKey: String) = findTerminal(terminalKey)!! } \ No newline at end of file diff --git a/ui/build.gradle b/ui/build.gradle index 28fc0013..d2bf6be5 100644 --- a/ui/build.gradle +++ b/ui/build.gradle @@ -58,6 +58,7 @@ dependencies { // threeds dependencies implementation "androidx.appcompat:appcompat:${appCompatVersion}" + implementation "androidx.fragment:fragment-ktx:1.5.5" implementation "androidx.lifecycle:lifecycle-runtime-ktx:${lifecycleRuntimeVersion}" implementation "androidx.constraintlayout:constraintlayout:${constraintLayoutVersion}" implementation "com.fasterxml.jackson.core:jackson-databind:${jacksonVersion}" diff --git a/ui/src/main/AndroidManifest.xml b/ui/src/main/AndroidManifest.xml index 39e367f1..65e36788 100644 --- a/ui/src/main/AndroidManifest.xml +++ b/ui/src/main/AndroidManifest.xml @@ -89,7 +89,7 @@ android:theme="@style/AcquiringTheme.Base"/> diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt index f320f936..af3908e7 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt @@ -33,7 +33,6 @@ import ru.tinkoff.acquiring.sdk.models.paysources.CardData import ru.tinkoff.acquiring.sdk.models.paysources.GooglePay import ru.tinkoff.acquiring.sdk.payment.PaymentProcess import ru.tinkoff.acquiring.sdk.requests.performSuspendRequest -import ru.tinkoff.acquiring.sdk.responses.GetTerminalPayMethodsResponse import ru.tinkoff.acquiring.sdk.responses.TerminalInfo import ru.tinkoff.acquiring.sdk.redesign.cards.list.ui.CardsListActivity import ru.tinkoff.acquiring.sdk.responses.TinkoffPayStatusResponse diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/models/NspkRequest.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/models/NspkRequest.kt index 5005f89a..78aa92d2 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/models/NspkRequest.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/models/NspkRequest.kt @@ -16,13 +16,17 @@ package ru.tinkoff.acquiring.sdk.models +import kotlinx.coroutines.CompletableDeferred import ru.tinkoff.acquiring.sdk.utils.NspkClient import ru.tinkoff.acquiring.sdk.utils.Request +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine /** * @author Mariya Chernyadieva */ -internal class NspkRequest: Request { +internal class NspkRequest : Request { @Volatile private var disposed = false @@ -39,4 +43,24 @@ internal class NspkRequest: Request { val client = NspkClient() client.call(this, onSuccess, onFailure) } + + suspend fun executeAsync(): Set { + val client = NspkClient() + val deferred = CompletableDeferred>() + client.call(this, onSuccess = { + deferred.complete(it.banks) + }, onFailure = { + deferred.completeExceptionally(it) + }) + return deferred.await() + } + + suspend fun execute(): NspkResponse { + return suspendCoroutine { continuation -> + execute( + onSuccess = { continuation.resume(it) }, + onFailure = { continuation.resumeWithException(it) } + ) + } + } } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentState.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentState.kt index f21ca6f2..63d20e27 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentState.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentState.kt @@ -16,7 +16,6 @@ package ru.tinkoff.acquiring.sdk.payment -import ru.tinkoff.acquiring.sdk.models.AsdkState import ru.tinkoff.acquiring.sdk.models.ThreeDsState /** @@ -45,12 +44,14 @@ enum class PaymentState { sealed interface YandexPaymentState { object Created : YandexPaymentState object Started : YandexPaymentState - class Registred(val paymentId: Long): YandexPaymentState + class Registred(val paymentId: Long) : YandexPaymentState object ThreeDsRejected : YandexPaymentState class ThreeDsUiNeeded(val asdkState: ThreeDsState) : YandexPaymentState class Error(val paymentId: Long?, val throwable: Throwable) : YandexPaymentState - class Success(val paymentId: Long,val cardId: String?, val rebillId: String?) : YandexPaymentState + class Success(val paymentId: Long, val cardId: String?, val rebillId: String?) : + YandexPaymentState + object Stopped : YandexPaymentState } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/SbpPaymentProcess.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/SbpPaymentProcess.kt new file mode 100644 index 00000000..9d25cdb9 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/SbpPaymentProcess.kt @@ -0,0 +1,197 @@ +package ru.tinkoff.acquiring.sdk.payment + +import android.content.pm.PackageManager +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import ru.tinkoff.acquiring.sdk.AcquiringSdk +import ru.tinkoff.acquiring.sdk.exceptions.AcquiringSdkException +import ru.tinkoff.acquiring.sdk.models.NspkRequest +import ru.tinkoff.acquiring.sdk.models.enums.DataTypeQr +import ru.tinkoff.acquiring.sdk.models.enums.ResponseStatus +import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions +import ru.tinkoff.acquiring.sdk.payment.PaymentProcess.Companion.configure +import ru.tinkoff.acquiring.sdk.redesign.sbp.util.NspkBankProvider +import ru.tinkoff.acquiring.sdk.redesign.sbp.util.SbpBankAppsProvider +import ru.tinkoff.acquiring.sdk.redesign.sbp.util.SbpHelper +import ru.tinkoff.acquiring.sdk.requests.performSuspendRequest + +/** + * Created by i.golovachev + */ +class SbpPaymentProcess internal constructor( + private val sdk: AcquiringSdk, + private val bankAppsProvider: SbpBankAppsProvider, + private val nspkBankProvider: NspkBankProvider, + private val scope: CoroutineScope +) { + internal constructor( + sdk: AcquiringSdk, + bankAppsProvider: SbpBankAppsProvider, + nspkBankProvider: NspkBankProvider, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO + ) : this(sdk, bankAppsProvider, nspkBankProvider, CoroutineScope(ioDispatcher)) + + val state = MutableStateFlow(SbpPaymentState.Created) + private var looperJob: Job = Job() + + fun start(paymentOptions: PaymentOptions) { + scope.launch { + runOrCatch { + val nspkApps = + nspkBankProvider.getNspkApps() + val init = sendInit(paymentOptions) + state.value = SbpPaymentState.Started(init.paymentId!!) + val deeplink = sendGetQr(init.paymentId) + + val installedApps = + bankAppsProvider.checkInstalledApps(nspkApps, deeplink) + state.value = + SbpPaymentState.NeedChooseOnUi(init.paymentId!!, installedApps, deeplink) + } + } + } + + fun goingToBankApp() { + val _state = state.value + when (_state) { + is SbpPaymentState.NeedChooseOnUi -> { + state.value = SbpPaymentState.LeaveOnBankApp(_state.paymentId) + } + is SbpPaymentState.Stopped -> { + state.value = SbpPaymentState.LeaveOnBankApp(_state.paymentId!!) + } + } + } + + fun startCheckingStatus(retriesCount: Int = 10) { + looperJob = scope.launch { + // выйдем из функции если стейт уже проверяется или вызов некорректен + val _state = state.value + if (_state is SbpPaymentState.LeaveOnBankApp || _state is SbpPaymentState.Stopped) { + StatusLooper(_state.paymentId!!, sdk, state).start(retriesCount) + } + } + } + + fun stop() { + state.value = SbpPaymentState.Stopped(state.value.paymentId) + if (looperJob.isActive) { + looperJob.cancel() + } + } + + private suspend fun runOrCatch(block: suspend () -> Unit) = try { + block() + } catch (throwable: Throwable) { + state.update { + if (throwable is CancellationException) { + SbpPaymentState.Stopped(it.paymentId) + } else { + SbpPaymentState.GetBankListFailed(it.paymentId, throwable) + } + } + } + + private suspend fun sendInit(paymentOptions: PaymentOptions) = + sdk.init { configure(paymentOptions) }.performSuspendRequest().getOrThrow() + + private suspend fun sendGetQr(paymentId: Long?) = checkNotNull( + sdk.getQr { + this.paymentId = paymentId + this.dataType = DataTypeQr.PAYLOAD + }.performSuspendRequest().getOrThrow().data, + ) { "data from NSPK are null" } + + class StatusLooper( + private val _paymentId: Long, + private val sdk: AcquiringSdk, + private val state: MutableStateFlow, + ) { + suspend fun start(retriesCount: Int) { + var tries = 0 + while (retriesCount > tries) { + val response = + sdk.getState { this.paymentId = _paymentId }.performSuspendRequest() + .getOrThrow() + delay(LOOPER_DELAY_MS) + val status = response.status + when (status) { + ResponseStatus.AUTHORIZED, ResponseStatus.CONFIRMED -> { + state.value = SbpPaymentState.Success( + _paymentId, null, null + ) + return + } + ResponseStatus.REJECTED -> { + state.value = SbpPaymentState.PaymentFailed( + _paymentId, + AcquiringSdkException(IllegalStateException("PaymentState = $status")) + ) + return + } + else -> { + tries += 1 + state.value = + SbpPaymentState.CheckingStatus(_paymentId, response.status) + } + } + } + state.value = SbpPaymentState.PaymentFailed( + _paymentId, + AcquiringSdkException(IllegalStateException("retriesCount is over")) + ) + } + } + + companion object { + private const val LOOPER_DELAY_MS = 3000L + private var instance: SbpPaymentProcess? = null + + @Synchronized + fun init( + sdk: AcquiringSdk, + packageManager: PackageManager, + bankAppsProvider: SbpBankAppsProvider = SbpBankAppsProvider { nspkBanks, dl -> + SbpHelper.getBankApps(packageManager, dl, nspkBanks) + }, + nspkBankProvider: NspkBankProvider = NspkBankProvider { + NspkRequest().execute().banks + } + ) { + instance?.scope?.cancel() + instance = SbpPaymentProcess(sdk, bankAppsProvider, nspkBankProvider) + } + + fun get() = instance!! + } +} + +sealed interface SbpPaymentState { + val paymentId: Long? + + object Created : SbpPaymentState { + override val paymentId: Long? = null + } + + class Started(override val paymentId: Long) : SbpPaymentState + class NeedChooseOnUi( + override val paymentId: Long, + val bankList: List, + val deeplink: String + ) : SbpPaymentState + + class GetBankListFailed(override val paymentId: Long?, val throwable: Throwable) : + SbpPaymentState + + class LeaveOnBankApp(override val paymentId: Long) : SbpPaymentState + class CheckingStatus( + override val paymentId: Long, + val status: ResponseStatus? + ) : SbpPaymentState + + class PaymentFailed(override val paymentId: Long?, val throwable: Throwable) : SbpPaymentState + class Success(override val paymentId: Long, val cardId: String?, val rebillId: String?) : + SbpPaymentState + + class Stopped(override val paymentId: Long?) : SbpPaymentState +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/YandexPaymentProcess.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/YandexPaymentProcess.kt index 0c814eb1..3ab6bd56 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/YandexPaymentProcess.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/YandexPaymentProcess.kt @@ -77,7 +77,7 @@ class YandexPaymentProcess( sendToListener(YandexPaymentState.Stopped) } - private fun sendToListener(state: YandexPaymentState?) { + private fun sendToListener(state: YandexPaymentState?) { this._state.update { state } } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentFailureInsufficientFundsDialogFragment.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentFailureInsufficientFundsDialogFragment.kt deleted file mode 100644 index 41074520..00000000 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentFailureInsufficientFundsDialogFragment.kt +++ /dev/null @@ -1,48 +0,0 @@ -package ru.tinkoff.acquiring.sdk.redesign.dialog - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import ru.tinkoff.acquiring.sdk.R -import ru.tinkoff.acquiring.sdk.ui.customview.LoaderButton -import ru.tinkoff.acquiring.sdk.utils.lazyView - -class PaymentFailureInsufficientFundsDialogFragment : BottomSheetDialogFragment() { - - private val buttonBackToPayment: LoaderButton by lazyView(R.id.acq_button_back_to_payment) - private val buttonOk: LoaderButton by lazyView(R.id.acq_button_ok) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setStyle(STYLE_NO_FRAME, theme) - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = - inflater.inflate(R.layout.acq_fragment_payment_failure_insufficient_funds, container, false) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - buttonBackToPayment.setOnClickListener { onBackToPayment() } - buttonOk.setOnClickListener { onOk() } - } - - private fun onBackToPayment() { - ((parentFragment as? OnBackToPayment) ?: (activity as? OnBackToPayment)) - ?.onPaymentFailureBackToPayment(this) - } - - private fun onOk() { - ((parentFragment as? OnOk) ?: (activity as? OnOk))?.onPaymentFailureOk(this) - } - - fun interface OnBackToPayment { - fun onPaymentFailureBackToPayment(fragment: PaymentFailureInsufficientFundsDialogFragment) - } - - fun interface OnOk { - fun onPaymentFailureOk(fragment: PaymentFailureInsufficientFundsDialogFragment) - } -} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusFormExt.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusFormExt.kt new file mode 100644 index 00000000..26d4ad09 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusFormExt.kt @@ -0,0 +1,47 @@ +package ru.tinkoff.acquiring.sdk.redesign.dialog + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import ru.tinkoff.acquiring.sdk.R + +interface OnPaymentSheetCloseListener { + fun onClose(state: PaymentSheetStatus) +} + +fun T.createPaymentSheetWrapper(): PaymentStatusSheet where T : FragmentActivity, T : OnPaymentSheetCloseListener { + return PaymentStatusSheet() +} + +fun T.createPaymentSheetWrapper(): PaymentStatusSheet where T : Fragment, T : OnPaymentSheetCloseListener { + return PaymentStatusSheet() +} + +sealed class PaymentSheetStatus( + open val title: Int?, + open val subtitle: Int? = null, + open val mainButton: Int? = null, + open val secondButton: Int? = null +) { + + object NotYet : PaymentSheetStatus(null) + + data class Progress( + override val title: Int, + override val subtitle: Int? = null, + override val secondButton: Int? = null + ) : PaymentSheetStatus(title, subtitle, null, secondButton) + + class Error( + title: Int, subtitle: Int? = null, mainButton: Int? = null, + secondButton: Int? = null, val throwable: Throwable + ) : PaymentSheetStatus(title, subtitle, mainButton, secondButton) + + class Success( + title: Int = R.string.acq_commonsheet_paid_title, + subtitle: Int? = null, + mainButton: Int? = R.string.acq_commonsheet_clear_primarybutton, + val paymentId: Long + ) : PaymentSheetStatus(title, subtitle, mainButton) + + object Hide : PaymentSheetStatus(null) +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusSheet.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusSheet.kt new file mode 100644 index 00000000..31c49fe0 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusSheet.kt @@ -0,0 +1,133 @@ +package ru.tinkoff.acquiring.sdk.redesign.dialog + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.core.view.isVisible +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.progressindicator.CircularProgressIndicator +import ru.tinkoff.acquiring.sdk.R +import ru.tinkoff.acquiring.sdk.ui.customview.LoaderButton + +class PaymentStatusSheet internal constructor(): BottomSheetDialogFragment() { + private lateinit var icon: ImageView + private lateinit var progress: CircularProgressIndicator + private lateinit var title: TextView + private lateinit var subtitle: TextView + private lateinit var mainButton: LoaderButton + private lateinit var secondButton: LoaderButton + + @Suppress("UNCHECKED_CAST") + private val onCloseListener: OnPaymentSheetCloseListener + get() { + val listener = (parentFragment as? OnPaymentSheetCloseListener) + ?: (activity as? OnPaymentSheetCloseListener) + return checkNotNull(listener) { + "parent of fragment not implemented OnPaymentSheetCloseListener" + } + } + + var state: PaymentSheetStatus? = null + set(value) { + field = value + if (value != null && isResumed) { + showState(value) + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + setStyle(STYLE_NO_FRAME, R.style.BottomSheetDialog) + return super.onCreateDialog(savedInstanceState) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? = + inflater.inflate(R.layout.acq_payment_status_form, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + dialog?.window?.attributes?.windowAnimations = R.style.AcqBottomSheetAnim + icon = view.findViewById(R.id.acq_payment_status_form_icon) + progress = view.findViewById(R.id.acq_payment_status_formm_progress) + title = view.findViewById(R.id.acq_payment_status_form_title) + subtitle = view.findViewById(R.id.acq_payment_status_form_subtitle) + mainButton = view.findViewById(R.id.acq_payment_status_form_main_button) + secondButton = view.findViewById(R.id.acq_payment_status_form_second_button) + state?.let(::showState) + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + state?.let { + onCloseListener.onClose(it) + } + } + + private fun set( + icon: Int?, + title: Int?, + subtitle: Int?, + mainButton: Int?, + secondButton: Int?, + progress: Boolean = icon == null, + isCancelable: Boolean = progress.not() && secondButton == null + ) { + if (icon != null) + this.icon.setImageResource(icon) + + this.icon.isVisible = icon != null + + if (title != null) + this.title.setText(title) + + this.title.isVisible = title != null + + if (subtitle != null) + this.subtitle.setText(subtitle) + + this.subtitle.isVisible = title != null + + if (mainButton != null) + this.mainButton.text = getString(mainButton) + + this.mainButton.isVisible = mainButton != null + + if (secondButton != null) + this.mainButton.text = getString(secondButton) + + this.secondButton.isVisible = secondButton != null + + this.progress.isVisible = progress + + this.isCancelable = isCancelable + } + + private fun showState(state: PaymentSheetStatus) { + set( + icon = defineIcon(state), + title = state.title, + subtitle = state.subtitle, + mainButton = state.mainButton, + secondButton = state.secondButton, + ) + + this.mainButton.setOnClickListener { onCloseListener.onClose(state) } + this.secondButton.setOnClickListener { onCloseListener.onClose(state) } + } + + private fun defineIcon(state: PaymentSheetStatus) = when (state) { + is PaymentSheetStatus.Error -> R.drawable.acq_ic_cross_circle + is PaymentSheetStatus.NotYet -> null + is PaymentSheetStatus.Progress -> null + is PaymentSheetStatus.Hide -> null + is PaymentSheetStatus.Success -> R.drawable.acq_ic_check_circle_positive + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/BankListViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/BankListViewModel.kt deleted file mode 100644 index 61972597..00000000 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/BankListViewModel.kt +++ /dev/null @@ -1,57 +0,0 @@ -package ru.tinkoff.acquiring.sdk.redesign.sbp.ui - -import androidx.lifecycle.ViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import ru.tinkoff.acquiring.sdk.models.NspkRequest -import ru.tinkoff.acquiring.sdk.models.NspkResponse -import ru.tinkoff.acquiring.sdk.utils.ConnectionChecker -import ru.tinkoff.acquiring.sdk.utils.CoroutineManager - -internal class BankListViewModel( - private val bankAppsProvider: BankAppsProvider, - private val connectionChecker: ConnectionChecker, - private val manager: CoroutineManager = CoroutineManager() -) : ViewModel() { - - val stateUiFlow = MutableStateFlow(BankListState.Shimmer) - - fun loadData() { - if (connectionChecker.isOnline().not()) { - stateUiFlow.tryEmit(BankListState.NoNetwork) - return - } - stateUiFlow.tryEmit(BankListState.Shimmer) - manager.launchOnBackground { - manager.call(NspkRequest(), - onSuccess = this@BankListViewModel::handleGetBankListResponse, - onFailure = this@BankListViewModel::handleGetBankListError) - } - } - - @Suppress("UNCHECKED_CAST") - private fun handleGetBankListResponse(nspk: NspkResponse) { - try { - val banks = bankAppsProvider.getBankApps(nspk.banks) - stateUiFlow.value = if (banks.isEmpty()) { - BankListState.Empty - } else { - BankListState.Empty - } - } catch (e: Exception) { - handleGetBankListError(e) - } - } - - private fun handleGetBankListError(it: Exception) { - stateUiFlow.value = BankListState.Error(it) - } - - override fun onCleared() { - manager.cancelAll() - super.onCleared() - } - - fun interface BankAppsProvider { - fun getBankApps(nspkBanks: Set): List - } -} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/BankListActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentActivity.kt similarity index 50% rename from ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/BankListActivity.kt rename to ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentActivity.kt index 78f8558e..13c7ab46 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/BankListActivity.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentActivity.kt @@ -19,6 +19,7 @@ package ru.tinkoff.acquiring.sdk.redesign.sbp.ui import android.annotation.SuppressLint import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -28,25 +29,39 @@ import android.widget.LinearLayout import android.widget.TextView import android.widget.ViewFlipper import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.core.view.children import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import ru.tinkoff.acquiring.sdk.R import ru.tinkoff.acquiring.sdk.TinkoffAcquiring +import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions import ru.tinkoff.acquiring.sdk.redesign.common.util.AcqShimmerAnimator -import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.BankListActivity.Companion.SBP_BANK_RESULT_CODE_NO_BANKS -import ru.tinkoff.acquiring.sdk.redesign.sbp.util.SbpHelper +import ru.tinkoff.acquiring.sdk.redesign.dialog.* +import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.SbpPaymentActivity.Companion.EXTRA_PAYMENT_ID +import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.SbpPaymentActivity.Companion.SBP_BANK_RESULT_CODE_NO_BANKS +import ru.tinkoff.acquiring.sdk.redesign.sbp.util.SbpHelper.openSbpDeeplink import ru.tinkoff.acquiring.sdk.utils.ConnectionChecker +import ru.tinkoff.acquiring.sdk.utils.lazyUnsafe import ru.tinkoff.acquiring.sdk.utils.lazyView import ru.tinkoff.acquiring.sdk.utils.showById -internal class BankListActivity : AppCompatActivity() { +internal class SbpPaymentActivity : AppCompatActivity(), OnPaymentSheetCloseListener { - private lateinit var viewModel: BankListViewModel + private val paymentOptions: PaymentOptions by lazyUnsafe { + intent.getParcelableExtra(EXTRA_PAYMENT_OPTIONS)!! + } + + private val viewModel: SbpPaymentViewModel by viewModels { + SbpPaymentViewModel.factory( + ConnectionChecker(application), + ) + } + + private val statusFragment: PaymentStatusSheet = createPaymentSheetWrapper() private val recyclerView: RecyclerView by lazyView(R.id.acq_bank_list_content) private val cardShimmer: LinearLayout by lazyView(R.id.acq_bank_list_shimmer) @@ -57,7 +72,6 @@ internal class BankListActivity : AppCompatActivity() { private val stubButtonView: TextView by lazyView(R.id.acq_stub_retry_button) private lateinit var deeplink: String - private var banks: List? = null @SuppressLint("NotifyDataSetChanged") set(value) { @@ -68,18 +82,21 @@ internal class BankListActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.acq_activity_bank_list) - deeplink = intent.getStringExtra(EXTRA_DEEPLINK)!! - viewModel = BankListViewModel({ nspkBanks -> - SbpHelper.getBankApps(packageManager, deeplink, nspkBanks) - }, ConnectionChecker(application)) - viewModel.loadData() + if (savedInstanceState == null) { + viewModel.loadData(paymentOptions) + } initToolbar() initViews() subscribeOnState() } + override fun onResume() { + super.onResume() + viewModel.startCheckingStatus() + } + private fun initToolbar() { setSupportActionBar(findViewById(R.id.acq_toolbar)) supportActionBar?.setDisplayHomeAsUpEnabled(true) @@ -93,69 +110,92 @@ internal class BankListActivity : AppCompatActivity() { } override fun onBackPressed() { + viewModel.cancelPayment() setResult(RESULT_CANCELED) finish() } + override fun onClose(status: PaymentSheetStatus) { + when (status) { + is PaymentSheetStatus.Error -> finishWithError(status.throwable) + is PaymentSheetStatus.Progress -> { + viewModel.cancelPayment() + statusFragment.dismiss() + } + is PaymentSheetStatus.Success -> finishWithResult(status.paymentId) + else -> Unit + } + } + private fun initViews() { recyclerView.adapter = Adapter() } private fun subscribeOnState() { - lifecycleScope.launch { - subscribeOnUiState() - } + lifecycleScope.launch { subscribeOnUiState() } + lifecycleScope.launch { subscribeOnSheetState() } } - private fun CoroutineScope.subscribeOnUiState() { - launch { - viewModel.stateUiFlow.collectLatest { - when (it) { - is BankListState.Content -> { - viewFlipper.showById(R.id.acq_bank_list_content) - banks = it.banks - } - is BankListState.Shimmer -> { - viewFlipper.showById(R.id.acq_bank_list_shimmer) - AcqShimmerAnimator.animateSequentially( - cardShimmer.children.toList() - ) - } - is BankListState.Error -> { - showStub( - imageResId = R.drawable.acq_ic_generic_error_stub, - titleTextRes = R.string.acq_generic_alert_label, - subTitleTextRes = R.string.acq_generic_stub_description, - buttonTextRes = R.string.acq_generic_alert_access - ) - stubButtonView.setOnClickListener { _ -> finishWithError(it.throwable) } - } - is BankListState.NoNetwork -> { - showStub( - imageResId = R.drawable.acq_ic_no_network, - titleTextRes = R.string.acq_generic_stubnet_title, - subTitleTextRes = R.string.acq_generic_stubnet_description, - buttonTextRes = R.string.acq_generic_button_stubnet - ) - stubButtonView.setOnClickListener { - viewModel.loadData() - } - } - is BankListState.Empty -> { - setResult(SBP_BANK_RESULT_CODE_NO_BANKS) - finish() + private suspend fun subscribeOnUiState() { + viewModel.stateUiFlow.collectLatest { + when (it) { + is SpbBankListState.Content -> { + viewFlipper.showById(R.id.acq_bank_list_content) + banks = it.banks + deeplink = it.deeplink + } + is SpbBankListState.Shimmer -> { + viewFlipper.showById(R.id.acq_bank_list_shimmer) + AcqShimmerAnimator.animateSequentially( + cardShimmer.children.toList() + ) + } + is SpbBankListState.Error -> { + showStub( + imageResId = R.drawable.acq_ic_generic_error_stub, + titleTextRes = R.string.acq_generic_alert_label, + subTitleTextRes = R.string.acq_generic_stub_description, + buttonTextRes = R.string.acq_generic_alert_access + ) + stubButtonView.setOnClickListener { _ -> finishWithError(it.throwable) } + } + is SpbBankListState.NoNetwork -> { + showStub( + imageResId = R.drawable.acq_ic_no_network, + titleTextRes = R.string.acq_generic_stubnet_title, + subTitleTextRes = R.string.acq_generic_stubnet_description, + buttonTextRes = R.string.acq_generic_button_stubnet + ) + stubButtonView.setOnClickListener { + viewModel.loadData(paymentOptions) } } + is SpbBankListState.Empty -> { + setResult(SBP_BANK_RESULT_CODE_NO_BANKS) + finish() + } } } } - private fun onBankSelected(packageName: String) { - setResult(RESULT_OK, Intent().apply { - putExtra(EXTRA_DEEPLINK, deeplink) - putExtra(EXTRA_PACKAGE_NAME, packageName) - }) - finish() + private suspend fun subscribeOnSheetState() { + viewModel.paymentStateFlow.collect { + statusFragment.state = it + when (it) { + is PaymentSheetStatus.Hide -> if (statusFragment.isAdded) { + statusFragment.dismiss() + } + is PaymentSheetStatus.NotYet -> Unit + else -> if (statusFragment.isAdded.not()) { + statusFragment.show(supportFragmentManager, null) + } + } + } + } + + private fun onBankSelected(packageName: String, deeplink: String) { + viewModel.onGoingToBankApp() + openSbpDeeplink(deeplink, packageName, this) } private fun showStub( @@ -177,6 +217,13 @@ internal class BankListActivity : AppCompatActivity() { stubButtonView.setText(buttonTextRes) } + private fun finishWithResult(paymentId: Long) { + val intent = Intent() + intent.putExtra(TinkoffAcquiring.EXTRA_PAYMENT_ID, paymentId) + setResult(RESULT_OK, intent) + finish() + } + private fun finishWithError(throwable: Throwable) { setErrorResult(throwable) finish() @@ -191,10 +238,14 @@ internal class BankListActivity : AppCompatActivity() { inner class Adapter : RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH = - VH(LayoutInflater.from(this@BankListActivity).inflate( - R.layout.acq_bank_list_item, parent, false)) + VH( + LayoutInflater.from(this@SbpPaymentActivity).inflate( + R.layout.acq_bank_list_item, parent, false + ) + ) - override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(banks!![position]) + override fun onBindViewHolder(holder: VH, position: Int) = + holder.bind(banks!![position], deeplink) override fun getItemCount(): Int = banks?.size ?: 0 } @@ -204,54 +255,56 @@ internal class BankListActivity : AppCompatActivity() { private val logo = view.findViewById(R.id.acq_bank_list_item_logo) private val name = view.findViewById(R.id.acq_bank_list_item_name) - fun bind(packageName: String) { + fun bind(packageName: String, deeplink: String) { logo.setImageDrawable(packageManager.getApplicationIcon(packageName)) name.text = packageManager.getApplicationLabel( - packageManager.getApplicationInfo(packageName, 0)) + packageManager.getApplicationInfo(packageName, 0) + ) itemView.setOnClickListener { - onBankSelected(packageName) + onBankSelected(packageName, deeplink) } } } companion object { + internal const val EXTRA_PAYMENT_ID = "extra_payment_id" internal const val EXTRA_DEEPLINK = "extra_deeplink" internal const val EXTRA_PACKAGE_NAME = "extra_package_name" + internal const val EXTRA_PAYMENT_OPTIONS = "extra_payment_options" internal const val SBP_BANK_RESULT_CODE_NO_BANKS = 501 } } -sealed class BankListState { - object Shimmer : BankListState() - object Empty : BankListState() - class Error(val throwable: Throwable) : BankListState() - object NoNetwork : BankListState() - - class Content(val banks: List) : BankListState() +sealed class SpbBankListState { + object Shimmer : SpbBankListState() + object Empty : SpbBankListState() + class Error(val throwable: Throwable) : SpbBankListState() + object NoNetwork : SpbBankListState() + class Content(val banks: List, val deeplink: String) : SpbBankListState() } -object BankList { +object SbpResult { sealed class Result - class Success(val deeplink: String, val packageName: String) : Result() + class Success(val payment: Long) : Result() class Canceled : Result() class Error(val error: Throwable) : Result() class NoBanks() : Result() - object Contract : ActivityResultContract() { + object Contract : ActivityResultContract() { - override fun createIntent(context: Context, deeplink: String): Intent = - Intent(context, BankListActivity::class.java).apply { - putExtra(BankListActivity.EXTRA_DEEPLINK, deeplink) + override fun createIntent(context: Context, paymentOptions: PaymentOptions): Intent = + Intent(context, SbpPaymentActivity::class.java).apply { + putExtra(SbpPaymentActivity.EXTRA_PAYMENT_OPTIONS, paymentOptions) } override fun parseResult(resultCode: Int, intent: Intent?): Result = when (resultCode) { AppCompatActivity.RESULT_OK -> Success( - intent!!.getStringExtra(BankListActivity.EXTRA_DEEPLINK)!!, - intent.getStringExtra(BankListActivity.EXTRA_PACKAGE_NAME)!!) + intent!!.getLongExtra(EXTRA_PAYMENT_ID, 0), + ) TinkoffAcquiring.RESULT_ERROR -> Error(intent!!.getSerializableExtra(TinkoffAcquiring.EXTRA_ERROR)!! as Throwable) SBP_BANK_RESULT_CODE_NO_BANKS -> NoBanks() else -> Canceled() diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentViewModel.kt new file mode 100644 index 00000000..76b3aa2c --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentViewModel.kt @@ -0,0 +1,67 @@ +package ru.tinkoff.acquiring.sdk.redesign.sbp.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import kotlinx.coroutines.flow.MutableStateFlow +import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions +import ru.tinkoff.acquiring.sdk.payment.SbpPaymentProcess +import ru.tinkoff.acquiring.sdk.redesign.dialog.PaymentSheetStatus +import ru.tinkoff.acquiring.sdk.redesign.sbp.util.SbpStateMapper +import ru.tinkoff.acquiring.sdk.utils.ConnectionChecker +import ru.tinkoff.acquiring.sdk.utils.CoroutineManager +import ru.tinkoff.acquiring.sdk.utils.updateIfNotNull + +internal class SbpPaymentViewModel( + private val connectionChecker: ConnectionChecker, + private val sbpPaymentProcess: SbpPaymentProcess, + private val manager: CoroutineManager = CoroutineManager(), + private val stateMapper: SbpStateMapper = SbpStateMapper() +) : ViewModel() { + + val stateUiFlow = MutableStateFlow(SpbBankListState.Shimmer) + val paymentStateFlow = MutableStateFlow(PaymentSheetStatus.NotYet) + + init { + manager.launchOnBackground { + sbpPaymentProcess.state.collect { + stateUiFlow.updateIfNotNull(stateMapper.mapUiState(it)) + paymentStateFlow.updateIfNotNull(stateMapper.mapStatusForm(it)) + } + } + } + + fun loadData(paymentOptions: PaymentOptions) { + if (connectionChecker.isOnline().not()) { + stateUiFlow.value = SpbBankListState.NoNetwork + return + } + stateUiFlow.value = SpbBankListState.Shimmer + sbpPaymentProcess.start(paymentOptions) + } + + fun onGoingToBankApp() { + sbpPaymentProcess.goingToBankApp() + } + + fun startCheckingStatus() { + sbpPaymentProcess.startCheckingStatus() + } + + fun cancelPayment() { + sbpPaymentProcess.stop() + } + + override fun onCleared() { + manager.cancelAll() + super.onCleared() + } + + companion object { + fun factory( + connectionChecker: ConnectionChecker, + ) = viewModelFactory { + initializer { SbpPaymentViewModel(connectionChecker, SbpPaymentProcess.get()) } + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/NspkBankProvider.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/NspkBankProvider.kt new file mode 100644 index 00000000..98916bca --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/NspkBankProvider.kt @@ -0,0 +1,5 @@ +package ru.tinkoff.acquiring.sdk.redesign.sbp.util + +fun interface NspkBankProvider { + suspend fun getNspkApps() : Set +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpBankChecker.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpBankChecker.kt new file mode 100644 index 00000000..d7d3b79e --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpBankChecker.kt @@ -0,0 +1,10 @@ +package ru.tinkoff.acquiring.sdk.redesign.sbp.util + +/** + * Created by i.golovachev + */ +fun interface SbpBankAppsProvider { + + fun checkInstalledApps(nspkBanks: Set, deeplink: String): List +} + diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpHelper.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpHelper.kt index ed852dc3..ca681d61 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpHelper.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpHelper.kt @@ -5,10 +5,6 @@ import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.FragmentManager -import ru.tinkoff.acquiring.sdk.redesign.dialog.OpenBankProgressDialogFragment -import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.BankListActivity object SbpHelper { diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpStateMapper.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpStateMapper.kt new file mode 100644 index 00000000..1760a6d9 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpStateMapper.kt @@ -0,0 +1,70 @@ +package ru.tinkoff.acquiring.sdk.redesign.sbp.util + +import ru.tinkoff.acquiring.sdk.R +import ru.tinkoff.acquiring.sdk.exceptions.AcquiringSdkTimeoutException +import ru.tinkoff.acquiring.sdk.models.enums.ResponseStatus +import ru.tinkoff.acquiring.sdk.payment.SbpPaymentState +import ru.tinkoff.acquiring.sdk.redesign.dialog.PaymentSheetStatus +import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.SpbBankListState + +/** + * Created by i.golovachev + */ +class SbpStateMapper { + + fun mapUiState(it: SbpPaymentState) = when (it) { + is SbpPaymentState.GetBankListFailed -> SpbBankListState.Error(it.throwable) + is SbpPaymentState.NeedChooseOnUi -> + if (it.bankList.isEmpty()) { + SpbBankListState.Empty + } else { + SpbBankListState.Content(it.bankList, it.deeplink) + } + else -> null + } + + fun mapStatusForm(it: SbpPaymentState): PaymentSheetStatus? { + return when (it) { + is SbpPaymentState.LeaveOnBankApp -> { + PaymentSheetStatus.Progress( + title = R.string.acq_commonsheet_payment_waiting_title, + secondButton = R.string.acq_commonsheet_payment_waiting_flat_button + ) + } + is SbpPaymentState.CheckingStatus -> { + val status = it.status + if (status == ResponseStatus.FORM_SHOWED) { + PaymentSheetStatus.Progress( + title = R.string.acq_commonsheet_payment_waiting_title, + secondButton = R.string.acq_commonsheet_payment_waiting_flat_button + ) + } else { + PaymentSheetStatus.Progress( + title = R.string.acq_commonsheet_processing_title, + subtitle = R.string.acq_commonsheet_processing_description + ) + } + } + is SbpPaymentState.Success -> + PaymentSheetStatus.Success(paymentId = it.paymentId) + is SbpPaymentState.PaymentFailed -> + if (it.throwable is AcquiringSdkTimeoutException) { + PaymentSheetStatus.Error( + title = R.string.acq_commonsheet_timeout_failed_title, + subtitle = R.string.acq_commonsheet_timeout_failed_description, + throwable = it.throwable, + secondButton = R.string.acq_commonsheet_timeout_failed_flat_button + ) + } else { + PaymentSheetStatus.Error( + title = R.string.acq_commonsheet_failed_title, + subtitle = R.string.acq_commonsheet_failed_description, + throwable = it.throwable, + mainButton = R.string.acq_commonsheet_failed_primary_button + ) + } + is SbpPaymentState.Stopped -> PaymentSheetStatus.Hide + else -> null + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt index 0ecc0bda..8eb5655e 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt @@ -26,37 +26,46 @@ import kotlin.coroutines.suspendCoroutine /** * @author Mariya Chernyadieva */ -internal class CoroutineManager(private val exceptionHandler: (Throwable) -> Unit, - private val io: CoroutineDispatcher = IO, - private val main : CoroutineDispatcher = Main) { +internal class CoroutineManager( + private val exceptionHandler: (Throwable) -> Unit, + private val io: CoroutineDispatcher = IO, + private val main: CoroutineDispatcher = Main +) { - constructor(io: CoroutineDispatcher = IO, - main : CoroutineDispatcher = Main) : this({}, io, main) + constructor( + io: CoroutineDispatcher = IO, + main: CoroutineDispatcher = Main + ) : this({}, io, main) private val job = SupervisorJob() - private val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable -> launchOnMain { exceptionHandler(throwable) } } + private val coroutineExceptionHandler = + CoroutineExceptionHandler { _, throwable -> launchOnMain { exceptionHandler(throwable) } } private val coroutineScope = CoroutineScope(Main + coroutineExceptionHandler + job) private val disposableSet = hashSetOf() - fun call(request: Request, onSuccess: (R) -> Unit, onFailure: ((Exception) -> Unit)? = null) { + fun call( + request: Request, + onSuccess: (R) -> Unit, + onFailure: ((Exception) -> Unit)? = null + ) { disposableSet.add(request) launchOnBackground { request.execute( - onSuccess = { - launchOnMain { - onSuccess(it) + onSuccess = { + launchOnMain { + onSuccess(it) + } + }, + onFailure = { + launchOnMain { + if (onFailure == null) { + exceptionHandler.invoke(it) + } else { + onFailure(it) } - }, - onFailure = { - launchOnMain { - if (onFailure == null) { - exceptionHandler.invoke(it) - } else { - onFailure(it) - } - } - }) + } + }) } } @@ -99,4 +108,19 @@ internal class CoroutineManager(private val exceptionHandler: (Throwable) -> Uni block.invoke(this) } } + + fun launchOnBackground( + block: suspend CoroutineScope.() -> Unit, + onError: (Throwable) -> Unit + ): Job { + return coroutineScope.launch(io) { + try { + block.invoke(this) + } catch (e: Throwable) { + if(e is CancellationException) { + onError(e) + } + } + } + } } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/FlowExt.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/FlowExt.kt new file mode 100644 index 00000000..e1d01d3b --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/FlowExt.kt @@ -0,0 +1,11 @@ +package ru.tinkoff.acquiring.sdk.utils + +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Created by i.golovachev + */ +fun MutableStateFlow.updateIfNotNull(value: T?) { + if (value == null) return + this.value = value +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/NspkClient.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/NspkClient.kt index 52883556..fc403d21 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/NspkClient.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/NspkClient.kt @@ -19,12 +19,14 @@ package ru.tinkoff.acquiring.sdk.utils import com.google.gson.Gson import com.google.gson.GsonBuilder import com.google.gson.JsonParseException +import okhttp3.OkHttpClient +import ru.tinkoff.acquiring.sdk.AcquiringSdk import ru.tinkoff.acquiring.sdk.exceptions.NetworkException import ru.tinkoff.acquiring.sdk.models.NspkResponse +import ru.tinkoff.acquiring.sdk.network.AcquiringApi import java.io.IOException -import java.io.InputStreamReader import java.net.HttpURLConnection -import java.net.URL +import java.util.concurrent.TimeUnit /** * @author Mariya Chernyadieva @@ -36,22 +38,33 @@ internal class NspkClient { private const val STREAM_BUFFER_SIZE = 4096 } - private val gson: Gson = GsonBuilder().create() + private val okHttpClient = OkHttpClient.Builder() + .connectTimeout(40000, TimeUnit.MILLISECONDS) + .readTimeout(40000, TimeUnit.MILLISECONDS) + .build() - fun call(request: Request, onSuccess: (NspkResponse) -> Unit, onFailure: (Exception) -> Unit) { - var responseReader: InputStreamReader? = null + private val gson: Gson = GsonBuilder().create() - try { - val targetUrl = URL(NSPK_ANDROID_APPS_URL) - val connection = targetUrl.openConnection() as HttpURLConnection - connection.requestMethod = "GET" - connection.connect() + fun call( + request: Request, + onSuccess: (NspkResponse) -> Unit, + onFailure: (Exception) -> Unit + ) { - val responseCode = connection.responseCode + val okHttpRequest = okhttp3.Request.Builder().url(NSPK_ANDROID_APPS_URL).get() + .header("User-Agent", System.getProperty("http.agent")!!) + .header("Accept", AcquiringApi.JSON) + .build() + val call = okHttpClient.newCall(okHttpRequest) + AcquiringSdk.log("=== Sending GET request to $NSPK_ANDROID_APPS_URL") + val okHttpResponse = call.execute() + val responseCode = okHttpResponse.code + val response = okHttpResponse.body?.string() + try { + AcquiringSdk.log("=== Got server response code: $responseCode") if (responseCode == HttpURLConnection.HTTP_OK) { - responseReader = InputStreamReader(connection.inputStream) - val response = read(responseReader) + AcquiringSdk.log("=== Got server response: $response") val banks: Set = (gson.fromJson(response, List::class.java) as List).map { ((it as Map<*, *>)["target"] as Map<*, *>)["package_name"] }.toSet() @@ -59,34 +72,22 @@ internal class NspkClient { onSuccess(NspkResponse(banks)) } } else { + AcquiringSdk.log("=== Got server response: $response") if (!request.isDisposed()) { onFailure(NetworkException("Got server error response code $responseCode")) } } } catch (e: IOException) { + AcquiringSdk.log("=== handle error on GET request to $NSPK_ANDROID_APPS_URL") if (!request.isDisposed()) { onFailure(e) } } catch (e: JsonParseException) { + AcquiringSdk.log("=== handle error on GET request to $NSPK_ANDROID_APPS_URL") if (!request.isDisposed()) { onFailure(e) } - } finally { - responseReader?.close() } } - - @Throws(IOException::class) - private fun read(reader: InputStreamReader): String { - val buffer = CharArray(STREAM_BUFFER_SIZE) - var read: Int = -1 - val result = StringBuilder() - - while ({ read = reader.read(buffer, 0, STREAM_BUFFER_SIZE); read }() != -1) { - result.append(buffer, 0, read) - } - - return result.toString() - } } \ No newline at end of file diff --git a/ui/src/main/res/drawable/acq_button_flat_bg.xml b/ui/src/main/res/drawable/acq_button_flat_bg.xml index 4a7587a7..d1966ce0 100644 --- a/ui/src/main/res/drawable/acq_button_flat_bg.xml +++ b/ui/src/main/res/drawable/acq_button_flat_bg.xml @@ -33,7 +33,8 @@ - + + diff --git a/ui/src/main/res/layout/acq_fragment_payment_failure_insufficient_funds.xml b/ui/src/main/res/layout/acq_payment_status_form.xml similarity index 58% rename from ui/src/main/res/layout/acq_fragment_payment_failure_insufficient_funds.xml rename to ui/src/main/res/layout/acq_payment_status_form.xml index c54b5e2f..6dbca2b6 100644 --- a/ui/src/main/res/layout/acq_fragment_payment_failure_insufficient_funds.xml +++ b/ui/src/main/res/layout/acq_payment_status_form.xml @@ -1,39 +1,54 @@ + android:orientation="vertical" + android:paddingBottom="24dp"> + + + android:textStyle="bold" + tools:text="Не получилось оплатить —\nнедостаточно денег на счету" /> + android:textSize="16sp" + tools:text="Пополните его или выберите другой" /> - \ No newline at end of file diff --git a/ui/src/main/res/values-ru/strings.xml b/ui/src/main/res/values-ru/strings.xml index a9689e5a..c3a42eae 100644 --- a/ui/src/main/res/values-ru/strings.xml +++ b/ui/src/main/res/values-ru/strings.xml @@ -80,8 +80,17 @@ Обрабатываем платеж Это займет некоторое время Оплачено - Не получилось оплатить + Ошибка при оплате + Попробуйте другой способ оплаты + Выбрать другой способ оплаты Понятно Воспользуйтесь другим\nспособом оплаты + Ждем оплату в приложении банка + Закрыть + + Время оплаты истекло + Попробуйте оплатить снова или выберите другой способ оплаты + Оплатить еще раз + Другой способ оплаты \ No newline at end of file diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 9ab22402..6d1a9240 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -29,9 +29,19 @@ Processing the payment it will take some time Paid + + Payment time has expired + Try to pay again or choose another payment method + Pay again + Other payment method + Payment error + Try to pay again or choose another payment method + Pay again OK Use a different payment method + Waiting for payment in the bank application + Close %1$s • %2$s Your cards diff --git a/ui/src/main/res/values/styles.xml b/ui/src/main/res/values/styles.xml index 0e936c7a..8552bd95 100644 --- a/ui/src/main/res/values/styles.xml +++ b/ui/src/main/res/values/styles.xml @@ -283,4 +283,11 @@ 32dp + + + diff --git a/ui/src/test/java/common/AssertExt.kt b/ui/src/test/java/common/AssertExt.kt index d6dc8193..e5ab1eac 100644 --- a/ui/src/test/java/common/AssertExt.kt +++ b/ui/src/test/java/common/AssertExt.kt @@ -9,6 +9,6 @@ fun assertByClassName(expected: Any?, actual: Any?) { Assert.assertEquals(expected?.javaClass?.simpleName, actual?.javaClass?.simpleName) } -fun assertByClassName(expected: Class<*>, actual: Class<*>) { - Assert.assertEquals(expected?.javaClass?.simpleName, actual?.javaClass?.simpleName) +inline fun assertViaClassName(expected: Class, actual: T) { + Assert.assertEquals(expected.simpleName, actual::class.java.simpleName) } \ No newline at end of file diff --git a/ui/src/test/java/sbp/SbpTestEnvironment.kt b/ui/src/test/java/sbp/SbpTestEnvironment.kt new file mode 100644 index 00000000..ac10c0e3 --- /dev/null +++ b/ui/src/test/java/sbp/SbpTestEnvironment.kt @@ -0,0 +1,102 @@ +package sbp + +import kotlinx.coroutines.* +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import ru.tinkoff.acquiring.sdk.AcquiringSdk +import ru.tinkoff.acquiring.sdk.payment.SbpPaymentProcess +import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.SbpPaymentViewModel +import ru.tinkoff.acquiring.sdk.redesign.sbp.util.NspkBankProvider +import ru.tinkoff.acquiring.sdk.redesign.sbp.util.SbpBankAppsProvider +import ru.tinkoff.acquiring.sdk.requests.GetQrRequest +import ru.tinkoff.acquiring.sdk.requests.GetStateRequest +import ru.tinkoff.acquiring.sdk.requests.InitRequest +import ru.tinkoff.acquiring.sdk.requests.performSuspendRequest +import ru.tinkoff.acquiring.sdk.responses.GetQrResponse +import ru.tinkoff.acquiring.sdk.responses.InitResponse +import ru.tinkoff.acquiring.sdk.utils.ConnectionChecker +import ru.tinkoff.acquiring.sdk.utils.CoroutineManager + +val nspkApps = setOf("ru.nspk.sbpay") + + +/** + * Created by i.golovachev + */ +internal class SbpTestEnvironment( + val connectionChecker: ConnectionChecker = mock { + on { isOnline() } doReturn true + }, + val bankAppsProvider: SbpBankAppsProvider = SbpBankAppsProvider { _, _ -> nspkApps.toList() }, + val nspkBankProvider: NspkBankProvider = NspkBankProvider { nspkApps }, + + // env + val dispatcher: CoroutineDispatcher = Dispatchers.Unconfined, + val processJob: Job = SupervisorJob(), + val paymentId: Long = 1, + val deeplink: String = "https://qr.nspk.ru/test_link", + + + // requests + val initRequest: InitRequest = mock(), + val getQrRequest: GetQrRequest = mock(), + val getState: GetStateRequest = mock() +) { + val sdk: AcquiringSdk = mock { + on { init(any()) } doReturn initRequest + on { getQr(any()) } doReturn getQrRequest + on { getState(any()) } doReturn getState + } + + val sbpPaymentProgress = SbpPaymentProcess(sdk, bankAppsProvider, nspkBankProvider, CoroutineScope( dispatcher + processJob)) + val viewModel: SbpPaymentViewModel = SbpPaymentViewModel( + connectionChecker, + sbpPaymentProgress, + CoroutineManager(dispatcher, dispatcher) + ) + + suspend fun setInitResult(throwable: Throwable) { + val result = Result.failure(throwable) + + whenever(initRequest.performSuspendRequest()) + .doReturn(result) + } + + suspend fun setInitResult(definePaymentId: Long? = null) { + val response = InitResponse(paymentId = definePaymentId) + val result = Result.success(response) + + whenever(initRequest.performSuspendRequest()) + .doReturn(result) + } + + suspend fun setGetQrResult(throwable: Throwable) { + val result = Result.failure(throwable) + + whenever(getQrRequest.performSuspendRequest()) + .doReturn(result) + } + + suspend fun setGetQrResult(deeplink: String) { + val response = GetQrResponse(data = deeplink) + val result = Result.success(response) + + whenever(getQrRequest.performSuspendRequest()) + .doReturn(result) + } +} + +internal fun SbpTestEnvironment.runWithEnv( + given: suspend SbpTestEnvironment.() -> Unit, + `when`: suspend SbpTestEnvironment.() -> Unit, + then: suspend SbpTestEnvironment.() -> Unit +) { + runBlocking { + launch { given.invoke(this@runWithEnv) }.join() + launch { `when`.invoke(this@runWithEnv) }.join() + launch { then.invoke(this@runWithEnv) }.join() + } +} + diff --git a/ui/src/test/java/sbp/ShowBanksApps.kt b/ui/src/test/java/sbp/ShowBanksApps.kt new file mode 100644 index 00000000..f6ece9c1 --- /dev/null +++ b/ui/src/test/java/sbp/ShowBanksApps.kt @@ -0,0 +1,28 @@ +package sbp + +import common.assertViaClassName +import org.junit.Test +import org.mockito.kotlin.mock +import ru.tinkoff.acquiring.sdk.payment.SbpPaymentState + +/** + * Created by i.golovachev + */ +class ShowBanksApps { + + @Test + fun `check progress WHEN start screen`() { + SbpTestEnvironment().runWithEnv( + given = { + setInitResult(definePaymentId = paymentId) + setGetQrResult(deeplink = deeplink) + }, + `when` = { + sbpPaymentProgress.start(mock()) + }, + then = { + assertViaClassName(SbpPaymentState.NeedChooseOnUi::class.java, sbpPaymentProgress.state.value) + } + ) + } +} \ No newline at end of file From ea6cd77db72eefd29eb927d974b1231eb22ddd46 Mon Sep 17 00:00:00 2001 From: jqwout Date: Wed, 25 Jan 2023 21:52:42 +0300 Subject: [PATCH 027/126] =?UTF-8?q?MC-8066=20=D0=BD=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=BE=D0=BF=D0=BB=D0=B0=D1=82=D0=B0=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ru/tinkoff/acquiring/sdk/utils/Request.kt | 8 + gradle/versions.gradle | 1 + .../acquiring/sample/ui/PayableActivity.kt | 163 ++++++++----- ui/build.gradle | 2 +- ui/src/main/AndroidManifest.xml | 23 +- .../tinkoff/acquiring/sdk/TinkoffAcquiring.kt | 3 + .../acquiring/sdk/models/NspkRequest.kt | 9 - .../sdk/payment/PaymentByCardProcess.kt | 201 ++++++++++++++++ .../acquiring/sdk/payment/PaymentProcess.kt | 3 - .../redesign/dialog/PaymentStatusFormExt.kt | 2 +- .../payment/ui/PaymentByCardActivity.kt | 219 ++++++++++++++++++ .../payment/ui/PaymentByCardViewModel.kt | 99 ++++++++ .../sdk/redesign/sbp/ui/SbpPaymentActivity.kt | 2 +- .../sdk/redesign/sbp/util/SbpStateMapper.kt | 2 +- .../acquiring/sdk/utils/CoroutineManager.kt | 8 +- .../acquiring/sdk/utils/FragmentExt.kt | 14 ++ .../tinkoff/acquiring/sdk/utils/IntentExt.kt | 29 +++ .../acquiring/sdk/viewmodel/QrViewModel.kt | 6 +- .../acq_payment_by_card_new_activity.xml | 61 +++++ 19 files changed, 769 insertions(+), 86 deletions(-) create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentByCardProcess.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardActivity.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardViewModel.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/FragmentExt.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/IntentExt.kt create mode 100644 ui/src/main/res/layout/acq_payment_by_card_new_activity.xml diff --git a/core/src/main/java/ru/tinkoff/acquiring/sdk/utils/Request.kt b/core/src/main/java/ru/tinkoff/acquiring/sdk/utils/Request.kt index 1bd8b5c7..0241d0c2 100644 --- a/core/src/main/java/ru/tinkoff/acquiring/sdk/utils/Request.kt +++ b/core/src/main/java/ru/tinkoff/acquiring/sdk/utils/Request.kt @@ -16,10 +16,18 @@ package ru.tinkoff.acquiring.sdk.utils +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + /** * @author Mariya Chernyadieva */ interface Request : Disposable { fun execute(onSuccess: (R) -> Unit, onFailure: (Exception) -> Unit) + + suspend fun execute(): R = suspendCoroutine { cttn -> + execute(onSuccess = { cttn.resume(it) }, onFailure = { cttn.resumeWithException(it) }) + } } \ No newline at end of file diff --git a/gradle/versions.gradle b/gradle/versions.gradle index da93b454..e4298ba4 100644 --- a/gradle/versions.gradle +++ b/gradle/versions.gradle @@ -12,6 +12,7 @@ ext { dokkaVersion = '1.7.10' appCompatVersion = '1.4.0' + fragmentKtxVersion = '1.5.5' preferenceVersion = '1.1.1' lifecycleExtensionsVersion = '2.5.1' cardIoVersion = '5.5.1' diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt index 7112b448..0e119b00 100644 --- a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt @@ -41,13 +41,12 @@ import ru.tinkoff.acquiring.sdk.models.AsdkState import ru.tinkoff.acquiring.sdk.models.GooglePayParams import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions import ru.tinkoff.acquiring.sdk.models.paysources.SbpPay -import ru.tinkoff.acquiring.sdk.payment.PaymentListener -import ru.tinkoff.acquiring.sdk.payment.PaymentListenerAdapter -import ru.tinkoff.acquiring.sdk.payment.PaymentState -import ru.tinkoff.acquiring.sdk.payment.SbpPaymentProcess +import ru.tinkoff.acquiring.sdk.payment.* +import ru.tinkoff.acquiring.sdk.redesign.payment.ui.PaymentByCardResult import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.SbpNoBanksStubActivity import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.SbpResult import ru.tinkoff.acquiring.sdk.redesign.sbp.util.SbpHelper +import ru.tinkoff.acquiring.sdk.threeds.ThreeDsHelper import ru.tinkoff.acquiring.sdk.utils.GooglePayHelper import ru.tinkoff.acquiring.sdk.utils.Money import ru.tinkoff.acquiring.yandexpay.YandexButtonFragment @@ -91,6 +90,17 @@ open class PayableActivity : AppCompatActivity() { is SbpResult.Canceled -> toast("SBP canceled") } } + private val byCardPayment = registerForActivityResult(PaymentByCardResult.Contract) { result -> + when (result) { + is PaymentByCardResult.Success -> { + toast("Payment Success") + } + is PaymentByCardResult.Error -> toast( + result.error.message ?: getString(R.string.error_title) + ) + is PaymentByCardResult.Canceled -> toast("Payment canceled") + } + } override fun onCreate(savedInstanceState: Bundle?) { @@ -123,7 +133,10 @@ open class PayableActivity : AppCompatActivity() { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { when (requestCode) { - PAYMENT_REQUEST_CODE, DYNAMIC_QR_PAYMENT_REQUEST_CODE -> handlePaymentResult(resultCode, data) + PAYMENT_REQUEST_CODE, DYNAMIC_QR_PAYMENT_REQUEST_CODE -> handlePaymentResult( + resultCode, + data + ) GOOGLE_PAY_REQUEST_CODE -> handleGooglePayResult(resultCode, data) YANDEX_PAY_REQUEST_CODE -> handleYandexPayResult(resultCode, data) else -> super.onActivityResult(requestCode, resultCode, data) @@ -156,11 +169,24 @@ open class PayableActivity : AppCompatActivity() { } protected fun initPayment() { - tinkoffAcquiring.openPaymentScreen(this, createPaymentOptions(), PAYMENT_REQUEST_CODE) + PaymentByCardProcess.init( + SampleApplication.tinkoffAcquiring.sdk, application, ThreeDsHelper.CollectData + ) + val options = createPaymentOptions().apply { + this.setTerminalParams( + terminalKey = TerminalsManager.selectedTerminal.terminalKey, + publicKey = TerminalsManager.selectedTerminal.publicKey + ) + } + byCardPayment.launch(options) } protected fun openDynamicQrScreen() { - tinkoffAcquiring.openDynamicQrScreen(this, createPaymentOptions(), DYNAMIC_QR_PAYMENT_REQUEST_CODE) + tinkoffAcquiring.openDynamicQrScreen( + this, + createPaymentOptions(), + DYNAMIC_QR_PAYMENT_REQUEST_CODE + ) } protected fun startSbpPayment() { @@ -186,8 +212,8 @@ open class PayableActivity : AppCompatActivity() { val version = status.getTinkoffPayVersion()!! tinkoffPayButton.setOnClickListener { tinkoffAcquiring.payWithTinkoffPay(createPaymentOptions(), version) - .subscribe(paymentListener) - .start() + .subscribe(paymentListener) + .start() } }) } @@ -204,11 +230,12 @@ open class PayableActivity : AppCompatActivity() { val paymentOptions = createPaymentOptions().apply { val session = TerminalsManager.init(this@PayableActivity).selectedTerminal this.setTerminalParams( - terminalKey = session.terminalKey, publicKey = session.publicKey + terminalKey = session.terminalKey, publicKey = session.publicKey ) } - val yaFragment = createYandexButtonFragment(savedInstanceState, paymentOptions, yandexPayData, theme) + val yaFragment = + createYandexButtonFragment(savedInstanceState, paymentOptions, yandexPayData, theme) if (supportFragmentManager.isDestroyed.not()) { supportFragmentManager.commit { replace(yandexPayButtonContainer.id, yaFragment) } @@ -225,8 +252,10 @@ open class PayableActivity : AppCompatActivity() { protected fun setupGooglePay() { val googlePayButton = findViewById(R.id.btn_google_pay) - val googleParams = GooglePayParams(TerminalsManager.selectedTerminalKey, - environment = SessionParams.GPAY_TEST_ENVIRONMENT) + val googleParams = GooglePayParams( + TerminalsManager.selectedTerminalKey, + environment = SessionParams.GPAY_TEST_ENVIRONMENT + ) val googlePayHelper = GooglePayHelper(googleParams) @@ -234,7 +263,11 @@ open class PayableActivity : AppCompatActivity() { if (ready) { googlePayButton.visibility = View.VISIBLE googlePayButton.setOnClickListener { - googlePayHelper.openGooglePay(this@PayableActivity, totalPrice, GOOGLE_PAY_REQUEST_CODE) + googlePayHelper.openGooglePay( + this@PayableActivity, + totalPrice, + GOOGLE_PAY_REQUEST_CODE + ) } } else { googlePayButton.visibility = View.GONE @@ -246,38 +279,39 @@ open class PayableActivity : AppCompatActivity() { val sessionParams = TerminalsManager.selectedTerminal return PaymentOptions() - .setOptions { - orderOptions { - orderId = this@PayableActivity.orderId - amount = totalPrice - title = this@PayableActivity.title - description = this@PayableActivity.description - recurrentPayment = settings.isRecurrentPayment - successURL = "https://www.google.com/search?q=success" - failURL = "https://www.google.com/search?q=fail" - additionalData = mutableMapOf( - "test_additional_data_key_1" to "test_additional_data_value_2", - "test_additional_data_key_2" to "test_additional_data_value_2") - } - customerOptions { - customerKey = sessionParams.customerKey - checkType = settings.checkType - email = sessionParams.customerEmail - } - featuresOptions { - localizationSource = AsdkSource(Language.RU) - handleCardListErrorInSdk = settings.handleCardListErrorInSdk - useSecureKeyboard = settings.isCustomKeyboardEnabled - validateExpiryDate = settings.validateExpiryDate - cameraCardScanner = settings.cameraScanner - fpsEnabled = settings.isFpsEnabled - tinkoffPayEnabled = settings.isTinkoffPayEnabled - darkThemeMode = settings.resolveDarkThemeMode() - theme = settings.resolvePaymentStyle() - userCanSelectCard = true - duplicateEmailToReceipt = true - } + .setOptions { + orderOptions { + orderId = this@PayableActivity.orderId + amount = totalPrice + title = this@PayableActivity.title + description = this@PayableActivity.description + recurrentPayment = settings.isRecurrentPayment + successURL = "https://www.google.com/search?q=success" + failURL = "https://www.google.com/search?q=fail" + additionalData = mutableMapOf( + "test_additional_data_key_1" to "test_additional_data_value_2", + "test_additional_data_key_2" to "test_additional_data_value_2" + ) } + customerOptions { + customerKey = sessionParams.customerKey + checkType = settings.checkType + email = sessionParams.customerEmail + } + featuresOptions { + localizationSource = AsdkSource(Language.RU) + handleCardListErrorInSdk = settings.handleCardListErrorInSdk + useSecureKeyboard = settings.isCustomKeyboardEnabled + validateExpiryDate = settings.validateExpiryDate + cameraCardScanner = settings.cameraScanner + fpsEnabled = settings.isFpsEnabled + tinkoffPayEnabled = settings.isTinkoffPayEnabled + darkThemeMode = settings.resolveDarkThemeMode() + theme = settings.resolvePaymentStyle() + userCanSelectCard = true + duplicateEmailToReceipt = true + } + } } private fun createPaymentListener(): PaymentListener { @@ -297,10 +331,11 @@ open class PayableActivity : AppCompatActivity() { override fun onUiNeeded(state: AsdkState) { hideProgressDialog() tinkoffAcquiring.openPaymentScreen( - this@PayableActivity, - createPaymentOptions(), - PAYMENT_REQUEST_CODE, - state) + this@PayableActivity, + createPaymentOptions(), + PAYMENT_REQUEST_CODE, + state + ) } override fun onError(throwable: Throwable, paymentId: Long?) { @@ -314,7 +349,8 @@ open class PayableActivity : AppCompatActivity() { private fun handlePaymentResult(resultCode: Int, data: Intent?) { when (resultCode) { RESULT_OK -> onSuccessPayment() - RESULT_CANCELED -> Toast.makeText(this, R.string.payment_cancelled, Toast.LENGTH_SHORT).show() + RESULT_CANCELED -> Toast.makeText(this, R.string.payment_cancelled, Toast.LENGTH_SHORT) + .show() RESULT_ERROR -> { Toast.makeText(this, R.string.payment_failed, Toast.LENGTH_SHORT).show() (data?.getSerializableExtra(TinkoffAcquiring.EXTRA_ERROR) as? Throwable)?.printStackTrace() @@ -327,7 +363,8 @@ open class PayableActivity : AppCompatActivity() { RESULT_OK -> { acqFragment?.options = createPaymentOptions() } - RESULT_CANCELED -> Toast.makeText(this, R.string.payment_cancelled, Toast.LENGTH_SHORT).show() + RESULT_CANCELED -> Toast.makeText(this, R.string.payment_cancelled, Toast.LENGTH_SHORT) + .show() RESULT_ERROR -> { Toast.makeText(this, R.string.payment_failed, Toast.LENGTH_SHORT).show() (data?.getSerializableExtra(TinkoffAcquiring.EXTRA_ERROR) as? Throwable)?.printStackTrace() @@ -343,9 +380,9 @@ open class PayableActivity : AppCompatActivity() { showErrorDialog() } else { SampleApplication.paymentProcess = tinkoffAcquiring - .initPayment(token, createPaymentOptions()) - .subscribe(paymentListener) - .start() + .initPayment(token, createPaymentOptions()) + .subscribe(paymentListener) + .start() } } else if (resultCode != Activity.RESULT_CANCELED) { showErrorDialog() @@ -378,20 +415,26 @@ open class PayableActivity : AppCompatActivity() { } } - private fun createYandexButtonFragment(savedInstanceState: Bundle?, - paymentOptions: PaymentOptions, - yandexPayData: YandexPayData, - theme: Int?) : YandexButtonFragment { + private fun createYandexButtonFragment( + savedInstanceState: Bundle?, + paymentOptions: PaymentOptions, + yandexPayData: YandexPayData, + theme: Int? + ): YandexButtonFragment { return savedInstanceState?.let { try { - (supportFragmentManager.getFragment(savedInstanceState, YANDEX_PAY_FRAGMENT_KEY) as? YandexButtonFragment)?.also { + (supportFragmentManager.getFragment( + savedInstanceState, + YANDEX_PAY_FRAGMENT_KEY + ) as? YandexButtonFragment)?.also { tinkoffAcquiring.addYandexResultListener( fragment = it, activity = this, yandexPayRequestCode = YANDEX_PAY_REQUEST_CODE, onYandexErrorCallback = { showErrorDialog() }, onYandexCancelCallback = { - Toast.makeText(this, R.string.payment_cancelled, Toast.LENGTH_SHORT).show() + Toast.makeText(this, R.string.payment_cancelled, Toast.LENGTH_SHORT) + .show() } ) } diff --git a/ui/build.gradle b/ui/build.gradle index d2bf6be5..835467ee 100644 --- a/ui/build.gradle +++ b/ui/build.gradle @@ -58,7 +58,7 @@ dependencies { // threeds dependencies implementation "androidx.appcompat:appcompat:${appCompatVersion}" - implementation "androidx.fragment:fragment-ktx:1.5.5" + implementation "androidx.fragment:fragment-ktx:${fragmentKtxVersion}" implementation "androidx.lifecycle:lifecycle-runtime-ktx:${lifecycleRuntimeVersion}" implementation "androidx.constraintlayout:constraintlayout:${constraintLayoutVersion}" implementation "com.fasterxml.jackson.core:jackson-databind:${jacksonVersion}" diff --git a/ui/src/main/AndroidManifest.xml b/ui/src/main/AndroidManifest.xml index 65e36788..33af265b 100644 --- a/ui/src/main/AndroidManifest.xml +++ b/ui/src/main/AndroidManifest.xml @@ -21,15 +21,21 @@ - + - + - + @@ -86,17 +92,22 @@ + android:theme="@style/AcquiringTheme.Base" /> + android:theme="@style/AcquiringTheme.Base" /> + android:theme="@style/AcquiringTheme.Base" /> + + { }) return deferred.await() } - - suspend fun execute(): NspkResponse { - return suspendCoroutine { continuation -> - execute( - onSuccess = { continuation.resume(it) }, - onFailure = { continuation.resumeWithException(it) } - ) - } - } } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentByCardProcess.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentByCardProcess.kt new file mode 100644 index 00000000..8f235b1c --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentByCardProcess.kt @@ -0,0 +1,201 @@ +package ru.tinkoff.acquiring.sdk.payment + +import android.app.Application +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import ru.tinkoff.acquiring.sdk.AcquiringSdk +import ru.tinkoff.acquiring.sdk.exceptions.AcquiringApiException +import ru.tinkoff.acquiring.sdk.models.PaymentSource +import ru.tinkoff.acquiring.sdk.models.ThreeDsState +import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions +import ru.tinkoff.acquiring.sdk.models.paysources.CardData +import ru.tinkoff.acquiring.sdk.models.paysources.CardSource +import ru.tinkoff.acquiring.sdk.models.result.PaymentResult +import ru.tinkoff.acquiring.sdk.network.AcquiringApi +import ru.tinkoff.acquiring.sdk.payment.PaymentProcess.Companion.configure +import ru.tinkoff.acquiring.sdk.requests.performSuspendRequest +import ru.tinkoff.acquiring.sdk.threeds.ThreeDsAppBasedTransaction +import ru.tinkoff.acquiring.sdk.threeds.ThreeDsDataCollector +import ru.tinkoff.acquiring.sdk.threeds.ThreeDsHelper +import ru.tinkoff.acquiring.sdk.utils.CoroutineManager +import ru.tinkoff.acquiring.sdk.utils.getIpAddress + +/** + * Created by i.golovachev + */ +class PaymentByCardProcess internal constructor( + private val sdk: AcquiringSdk, + private val application: Application, + private val threeDsDataCollector: ThreeDsDataCollector, + private val coroutineManager: CoroutineManager = CoroutineManager() +) { + + private lateinit var paymentSource: CardData + private val _state = MutableStateFlow(PaymentByCardState.Created) + val state = _state.asStateFlow() + + fun start( + cardData: CardData, + paymentOptions: PaymentOptions, + email: String? = null + ) { + _state.value = PaymentByCardState.Started(paymentOptions, email) + coroutineManager.launchOnBackground { + try { + callInitRequest(cardData, paymentOptions, email) + } catch (e: Throwable) { + handleException(e) + } + } + } + + fun stop() { + coroutineManager.cancelAll() + } + + private suspend fun callInitRequest( + cardData: CardData, + paymentOptions: PaymentOptions, + email: String? + ) { + this.paymentSource = cardData + val init = sdk.init { + configure(paymentOptions) + if (paymentOptions.features.duplicateEmailToReceipt && !email.isNullOrEmpty()) { + receipt?.email = email + } + }.execute() + + callCheck3DsVersion(init.paymentId!!, cardData, paymentOptions, email) + } + + private suspend fun callCheck3DsVersion( + paymentId: Long, + paymentSource: CardSource, + paymentOptions: PaymentOptions, + email: String? = null + ) { + + val check3Ds = sdk.check3DsVersion { + this.paymentId = paymentId + this.paymentSource = paymentSource + }.execute() + + val data = mutableMapOf() + if (check3Ds.serverTransId != null) { + coroutineManager.withMain { + data.putAll(threeDsDataCollector(application, check3Ds)) + } + } + val threeDsVersion = check3Ds.version + var threeDsTransaction: ThreeDsAppBasedTransaction? = null + + if (ThreeDsHelper.isAppBasedFlow(threeDsVersion)) { + try { + coroutineManager.withMain { + threeDsTransaction = ThreeDsHelper.CreateAppBasedTransaction( + application, threeDsVersion!!, check3Ds.paymentSystem!!, data + ) + } + } catch (e: Throwable) { + handleException(e) + return + } + } + + callFinishAuthorizeRequest( + paymentId, + paymentSource, + paymentOptions, + email, + data, + threeDsVersion, + threeDsTransaction + ) + } + + private suspend fun callFinishAuthorizeRequest( + paymentId: Long, + paymentSource: PaymentSource, + paymentOptions: PaymentOptions, + email: String? = null, + data: Map? = null, + threeDsVersion: String? = null, + threeDsTransaction: ThreeDsAppBasedTransaction? = null + ) { + val ipAddress = if (data != null) getIpAddress() else null + + val finishRequest = sdk.finishAuthorize { + this.paymentId = paymentId + this.email = email + this.paymentSource = paymentSource + this.data = data + ip = ipAddress + sendEmail = email != null + } + + val response = finishRequest.execute() + val threeDsData = response.getThreeDsData(threeDsVersion) + + _state.value = if (threeDsData.isThreeDsNeed) { + PaymentByCardState.ThreeDsUiNeeded( + ThreeDsState(threeDsData, threeDsTransaction), + paymentOptions + ) + } else { + PaymentByCardState.Success( + response.paymentId!!, + null, + response.rebillId + ) + } + } + + private fun handleException(throwable: Throwable) { + if (throwable is AcquiringApiException && throwable.response != null && + throwable.response!!.errorCode == AcquiringApi.API_ERROR_CODE_3DSV2_NOT_SUPPORTED + ) { + // todo + } else { + _state.update { PaymentByCardState.Error(throwable, null) } + } + } + + companion object { + + private var value: PaymentByCardProcess? = null + + fun get() = value!! + + @Synchronized + fun init( + sdk: AcquiringSdk, + application: Application, + threeDsDataCollector: ThreeDsDataCollector = ThreeDsHelper.CollectData + ) { + value = PaymentByCardProcess(sdk, application, threeDsDataCollector) + } + } +} + + +sealed interface PaymentByCardState { + object Created : PaymentByCardState + + class Started( + val paymentOptions: PaymentOptions, + val email: String? = null + ) : PaymentByCardState + + class ThreeDsUiNeeded(val threeDsState: ThreeDsState, val paymentOptions: PaymentOptions) : + PaymentByCardState + + class Success(val paymentId: Long, val cardId: String?, val rebillId: String?) : + PaymentByCardState { + internal val result = PaymentResult(paymentId, cardId, rebillId) + } + + class Error(val throwable: Throwable, val paymentId: Long?) : PaymentByCardState +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentProcess.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentProcess.kt index 4c48bd7c..3cb3f88e 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentProcess.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentProcess.kt @@ -308,9 +308,6 @@ internal constructor( onSuccess = { response -> val data = mutableMapOf() if (response.serverTransId != null) { - if (!response.threeDsMethodUrl.isNullOrEmpty()) { - this.check3dsVersionResponse = response - } data.putAll(ThreeDsHelper.CollectData(context, response)) } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusFormExt.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusFormExt.kt index 26d4ad09..e0953a03 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusFormExt.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusFormExt.kt @@ -40,7 +40,7 @@ sealed class PaymentSheetStatus( title: Int = R.string.acq_commonsheet_paid_title, subtitle: Int? = null, mainButton: Int? = R.string.acq_commonsheet_clear_primarybutton, - val paymentId: Long + val resultData: java.io.Serializable ) : PaymentSheetStatus(title, subtitle, mainButton) object Hide : PaymentSheetStatus(null) diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardActivity.kt new file mode 100644 index 00000000..6a9cca60 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardActivity.kt @@ -0,0 +1,219 @@ +package ru.tinkoff.acquiring.sdk.redesign.payment.ui + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.* +import kotlinx.coroutines.launch +import ru.tinkoff.acquiring.sdk.R +import ru.tinkoff.acquiring.sdk.TinkoffAcquiring +import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions +import ru.tinkoff.acquiring.sdk.models.paysources.CardData +import ru.tinkoff.acquiring.sdk.models.result.AsdkResult +import ru.tinkoff.acquiring.sdk.models.result.PaymentResult +import ru.tinkoff.acquiring.sdk.payment.PaymentByCardState +import ru.tinkoff.acquiring.sdk.redesign.common.carddatainput.CardDataInputFragment +import ru.tinkoff.acquiring.sdk.redesign.dialog.OnPaymentSheetCloseListener +import ru.tinkoff.acquiring.sdk.redesign.dialog.PaymentSheetStatus +import ru.tinkoff.acquiring.sdk.redesign.dialog.createPaymentSheetWrapper +import ru.tinkoff.acquiring.sdk.threeds.ThreeDsHelper +import ru.tinkoff.acquiring.sdk.ui.activities.TransparentActivity +import ru.tinkoff.acquiring.sdk.ui.customview.LoaderButton +import ru.tinkoff.acquiring.sdk.utils.lazyView +import ru.tinkoff.acquiring.sdk.utils.toBundle + +/** + * Created by i.golovachev + */ +internal class PaymentByCardActivity : AppCompatActivity(), + CardDataInputFragment.OnCardDataChanged, OnPaymentSheetCloseListener { + + private val cardDataInput + get() = supportFragmentManager.findFragmentById(R.id.fragment_card_data_input) as CardDataInputFragment + + private val payButton: LoaderButton by lazyView(R.id.acq_pay_btn) + + private val viewModel: PaymentByCardViewModel by viewModels { PaymentByCardViewModel.factory() } + + private val statusSheetStatus = createPaymentSheetWrapper() + + private var onPaymentInternal: OnPaymentSheetCloseListener? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.acq_payment_by_card_new_activity) + initToolbar() + + lifecycleScope.launchWhenCreated { buttonState() } + lifecycleScope.launch { processState() } + payButton.setOnClickListener { + viewModel.pay() + } + } + + override fun onResume() { + super.onResume() + + if (statusSheetStatus.state != null + && statusSheetStatus.state != PaymentSheetStatus.NotYet + && statusSheetStatus.state != PaymentSheetStatus.Hide + ) { + if (statusSheetStatus.isAdded.not()) { + statusSheetStatus.showNow(supportFragmentManager, null) + } + } + } + + override fun onCardDataChanged(isValid: Boolean) { + viewModel.setCardDate( + cardNumber = cardDataInput.cardNumber, + cvc = cardDataInput.cvc, + dateExpired = cardDataInput.expiryDate, + isValidCardData = isValid + ) + } + + override fun onClose(state: PaymentSheetStatus) { + + if (onPaymentInternal != null) { + onPaymentInternal?.onClose(state) + } else { + if (state is PaymentSheetStatus.Error) { + finishWithError(state.throwable) + } else { + finish() + } + } + } + + override fun onSupportNavigateUp(): Boolean { + onBackPressed() + return true + } + + override fun onBackPressed() { + viewModel.cancelPayment() + setResult(RESULT_CANCELED) + finish() + } + + private fun initToolbar() { + setSupportActionBar(findViewById(R.id.acq_toolbar)) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.setDisplayShowHomeEnabled(true) + supportActionBar?.setTitle(R.string.acq_banklist_title) + } + + private suspend fun buttonState() { + viewModel.state.collect { + payButton.text = it.amount + payButton.isEnabled = it.buttonEnabled + } + } + + private suspend fun processState() { + viewModel.paymentProcessState.collect { + payButton.isLoading = it is PaymentByCardState.Started + + when (it) { + is PaymentByCardState.Created -> Unit + is PaymentByCardState.Error -> { + statusSheetStatus.show(supportFragmentManager, null) + statusSheetStatus.state = PaymentSheetStatus.Error( + title = R.string.acq_commonsheet_failed_title, + mainButton = R.string.acq_commonsheet_failed_primary_button, + throwable = it.throwable + ) + } + is PaymentByCardState.Started -> Unit + is PaymentByCardState.Success -> Unit + is PaymentByCardState.ThreeDsUiNeeded -> try { + ThreeDsHelper.Launch.launchBrowserBased( + this, + TransparentActivity.THREE_DS_REQUEST_CODE, + it.paymentOptions, + it.threeDsState.data, + ) + } catch (e: Throwable) { + statusSheetStatus.show(supportFragmentManager, null) + statusSheetStatus.state = PaymentSheetStatus.Error( + title = R.string.acq_commonsheet_failed_title, + mainButton = R.string.acq_commonsheet_failed_primary_button, + throwable = e + ) + } + } + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == TransparentActivity.THREE_DS_REQUEST_CODE) { + if (resultCode == Activity.RESULT_OK && data != null) { + statusSheetStatus.state = PaymentSheetStatus.Success( + title = R.string.acq_commonsheet_paid_title, + mainButton = R.string.acq_commonsheet_clear_primarybutton, + resultData = data.getSerializableExtra(ThreeDsHelper.Launch.RESULT_DATA) as AsdkResult + ) + } else if (resultCode == ThreeDsHelper.Launch.RESULT_ERROR) { + statusSheetStatus.state = PaymentSheetStatus.Error( + title = R.string.acq_commonsheet_failed_title, + mainButton = R.string.acq_commonsheet_failed_primary_button, + throwable = data?.getSerializableExtra(ThreeDsHelper.Launch.ERROR_DATA) as Throwable + ) + } else { + setResult(Activity.RESULT_CANCELED) + finish() + } + } else { + super.onActivityResult(requestCode, resultCode, data) + } + } + + private fun finishWithError(throwable: Throwable) { + val intent = Intent() + intent.putExtra(TinkoffAcquiring.EXTRA_ERROR, throwable) + setResult(TinkoffAcquiring.RESULT_ERROR, intent) + finish() + } +} + +object PaymentByCardResult { + + private const val EXTRA_ASDK_RESULT = "asdk_result" + + sealed class Result + class Success( + val paymentId: Long? = null, + val cardId: String? = null, + val rebillId: String? = null + ) : Result() + + class Canceled : Result() + class Error(val error: Throwable) : Result() + + + object Contract : ActivityResultContract() { + + override fun createIntent(context: Context, paymentOptions: PaymentOptions): Intent = + Intent(context, PaymentByCardActivity::class.java).apply { + putExtras(paymentOptions.toBundle()) + } + + override fun parseResult(resultCode: Int, intent: Intent?): Result = when (resultCode) { + AppCompatActivity.RESULT_OK -> { + val result = intent!!.getSerializableExtra(EXTRA_ASDK_RESULT) as PaymentResult + Success( + result.paymentId, + result.cardId, + result.rebillId + ) + } + TinkoffAcquiring.RESULT_ERROR -> Error(intent!!.getSerializableExtra(TinkoffAcquiring.EXTRA_ERROR)!! as Throwable) + else -> Canceled() + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardViewModel.kt new file mode 100644 index 00000000..b37187cd --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardViewModel.kt @@ -0,0 +1,99 @@ +package ru.tinkoff.acquiring.sdk.redesign.payment.ui + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions +import ru.tinkoff.acquiring.sdk.models.paysources.CardData +import ru.tinkoff.acquiring.sdk.payment.PaymentByCardProcess +import ru.tinkoff.acquiring.sdk.utils.getExtra + +internal class PaymentByCardViewModel( + private val savedStateHandle: SavedStateHandle, + private val paymentByCardProcess: PaymentByCardProcess +) : ViewModel() { + val paymentProcessState = paymentByCardProcess.state + + val state: MutableStateFlow = + MutableStateFlow(State(paymentOptions = savedStateHandle.getExtra())) + + fun setCardDate( + cardNumber: String? = null, + cvc: String? = null, + dateExpired: String? = null, + isValidCardData: Boolean = false + ) = state.update { + it.copy( + cardNumber = cardNumber, + cvc = cvc, + dateExpired = dateExpired, + isValidCardData = isValidCardData + ) + } + + fun rememberCardChange(isSelect: Boolean) = state.update { + it.copy(rememberCard = isSelect) + } + + fun saveEmailChange(isSelect: Boolean) = state.update { + it.copy(sendReceipt = isSelect) + } + + fun setEmail(email: String?, isValidEmail: Boolean) = state.update { + it.copy(email = email, isValidEmail = isValidEmail) + } + + fun pay() { + paymentByCardProcess.start(state.value.cardData, state.value.paymentOptions) + } + + fun cancelPayment() { + paymentByCardProcess.stop() + } + + data class State( + private val cardNumber: String? = null, + private val cvc: String? = null, + private val dateExpired: String? = null, + private val isValidCardData: Boolean = false, + private val rememberCard: Boolean = false, + private val email: String? = null, + private val isValidEmail: Boolean = false, + private val sendReceipt: Boolean = false, + + val paymentOptions: PaymentOptions, + ) { + + val buttonEnabled: Boolean = if (sendReceipt) { + isValidCardData && isValidEmail + } else { + isValidCardData + } + + val amount = paymentOptions.order.amount.toHumanReadableString() + + val cardData: CardData + get() { + return CardData( + pan = cardNumber!!, + expiryDate = dateExpired!!, + securityCode = cvc!! + ).apply { + validate() + } + } + + } + + companion object { + fun factory() = viewModelFactory { + initializer { + PaymentByCardViewModel(createSavedStateHandle(), PaymentByCardProcess.get()) + } + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentActivity.kt index 13c7ab46..11bb2da3 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentActivity.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentActivity.kt @@ -122,7 +122,7 @@ internal class SbpPaymentActivity : AppCompatActivity(), OnPaymentSheetCloseList viewModel.cancelPayment() statusFragment.dismiss() } - is PaymentSheetStatus.Success -> finishWithResult(status.paymentId) + is PaymentSheetStatus.Success -> finishWithResult(status.resultData as Long) else -> Unit } } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpStateMapper.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpStateMapper.kt index 1760a6d9..10d4ecdd 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpStateMapper.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpStateMapper.kt @@ -46,7 +46,7 @@ class SbpStateMapper { } } is SbpPaymentState.Success -> - PaymentSheetStatus.Success(paymentId = it.paymentId) + PaymentSheetStatus.Success(resultData = it.paymentId) is SbpPaymentState.PaymentFailed -> if (it.throwable is AcquiringSdkTimeoutException) { PaymentSheetStatus.Error( diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt index 8eb5655e..30746121 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt @@ -103,6 +103,12 @@ internal class CoroutineManager( } } + suspend fun withMain(block: suspend CoroutineScope.() -> Unit) { + withContext(main) { + block.invoke(this) + } + } + fun launchOnBackground(block: suspend CoroutineScope.() -> Unit): Job { return coroutineScope.launch(io) { block.invoke(this) @@ -117,7 +123,7 @@ internal class CoroutineManager( try { block.invoke(this) } catch (e: Throwable) { - if(e is CancellationException) { + if (e is CancellationException) { onError(e) } } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/FragmentExt.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/FragmentExt.kt new file mode 100644 index 00000000..f5c5643d --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/FragmentExt.kt @@ -0,0 +1,14 @@ +@file:Suppress("UNCHECKED_CAST") + +package ru.tinkoff.acquiring.sdk.utils + +import androidx.fragment.app.Fragment +import ru.tinkoff.acquiring.sdk.redesign.common.carddatainput.CardDataInputFragment + +/** + * Created by i.golovachev + */ +fun Fragment.getParent() : T? { + val parent = (parentFragment as? T) ?: (activity as? T) + return parent +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/IntentExt.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/IntentExt.kt new file mode 100644 index 00000000..01f3ec8c --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/IntentExt.kt @@ -0,0 +1,29 @@ +package ru.tinkoff.acquiring.sdk.utils + +import android.content.Intent +import androidx.core.os.bundleOf +import androidx.lifecycle.SavedStateHandle +import ru.tinkoff.acquiring.sdk.models.options.screen.BaseAcquiringOptions + +/** + * Created by i.golovachev + */ + + +private const val EXTRA_OPTIONS = "options" + +fun bundleOfOptions(options: BaseAcquiringOptions) = bundleOf(EXTRA_OPTIONS to options) + +fun BaseAcquiringOptions.toBundle() = bundleOf(EXTRA_OPTIONS to this) + +fun Intent.getOptions(): T { + return checkNotNull(getParcelableExtra(EXTRA_OPTIONS)) { + "extra by key $EXTRA_OPTIONS not fount" + } +} + +fun SavedStateHandle.getExtra(): T { + return checkNotNull(get(EXTRA_OPTIONS)) { + "extra by key $EXTRA_OPTIONS not fount" + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/QrViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/QrViewModel.kt index 8f005756..80de50f5 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/QrViewModel.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/QrViewModel.kt @@ -66,7 +66,7 @@ internal class QrViewModel( coroutine.call(request, onSuccess = { response -> - qrImageResult.value = response.data + qrImageResult.value = response.data!! changeScreenState(LoadedState) }) } @@ -99,7 +99,7 @@ internal class QrViewModel( onSuccess = { when (type) { DataTypeQr.IMAGE -> { - qrImageResult.value = it.data + qrImageResult.value = it.data!! coroutine.runWithDelay(15000) { getState(paymentId) } @@ -118,7 +118,7 @@ internal class QrViewModel( coroutine.call(request, onSuccess = { response -> if (response.status == ResponseStatus.CONFIRMED || response.status == ResponseStatus.AUTHORIZED) { - paymentResult.value = response.paymentId + paymentResult.value = response.paymentId!! } else { coroutine.runWithDelay(5000) { getState(paymentId) diff --git a/ui/src/main/res/layout/acq_payment_by_card_new_activity.xml b/ui/src/main/res/layout/acq_payment_by_card_new_activity.xml new file mode 100644 index 00000000..f583caf3 --- /dev/null +++ b/ui/src/main/res/layout/acq_payment_by_card_new_activity.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + \ No newline at end of file From 29a69e0aa62e5be270e426a7cb7979a04bb1109e Mon Sep 17 00:00:00 2001 From: jqwout Date: Fri, 27 Jan 2023 11:31:55 +0300 Subject: [PATCH 028/126] =?UTF-8?q?MC-8067=20-=20=D0=B5=D0=BC=D0=B5=D0=B9?= =?UTF-8?q?=D0=BB=20=D0=B8=20=D0=B2=D0=BE=D0=B7=D0=BC=D0=BE=D0=B6=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=8C=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D1=8B?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=D0=B0=20=D0=BA=D0=B0=D1=80=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../acquiring/sample/ui/PayableActivity.kt | 15 +- .../tinkoff/acquiring/sdk/TinkoffAcquiring.kt | 25 +++ .../carddatainput/CardDataInputFragment.kt | 19 +- .../common/carddatainput/CvcComponent.kt | 74 +++++++ .../common/emailinput/EmailInputFragment.kt | 75 +++++++ .../redesign/dialog/PaymentStatusFormExt.kt | 26 ++- .../sdk/redesign/dialog/PaymentStatusSheet.kt | 16 +- .../redesign/payment/model/CardChosenModel.kt | 14 ++ .../payment/ui/ChosenCardComponent.kt | 36 +++ .../sdk/redesign/payment/ui/PaymentByCard.kt | 69 ++++++ .../payment/ui/PaymentByCardActivity.kt | 205 ++++++++++-------- .../payment/ui/PaymentByCardViewModel.kt | 33 ++- .../sdk/redesign/sbp/ui/SbpPaymentActivity.kt | 13 +- .../redesign/sbp/ui/SbpPaymentViewModel.kt | 4 +- .../sdk/redesign/sbp/util/SbpStateMapper.kt | 18 +- .../acquiring/sdk/ui/component/UiComponent.kt | 20 ++ .../tinkoff/acquiring/sdk/utils/IntentExt.kt | 6 +- .../res/layout/acq_fragment_cvc_input.xml | 11 + .../res/layout/acq_fragment_email_input.xml | 9 + .../res/layout/acq_layout_choosen_card.xml | 81 +++++++ .../acq_payment_by_card_new_activity.xml | 18 +- ui/src/main/res/values-ru/strings.xml | 4 + ui/src/main/res/values/strings.xml | 4 + 23 files changed, 646 insertions(+), 149 deletions(-) create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CvcComponent.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/emailinput/EmailInputFragment.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/model/CardChosenModel.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/ChosenCardComponent.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCard.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/component/UiComponent.kt create mode 100644 ui/src/main/res/layout/acq_fragment_cvc_input.xml create mode 100644 ui/src/main/res/layout/acq_fragment_email_input.xml create mode 100644 ui/src/main/res/layout/acq_layout_choosen_card.xml diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt index 0e119b00..dad3aefa 100644 --- a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt @@ -40,12 +40,10 @@ import ru.tinkoff.acquiring.sdk.localization.Language import ru.tinkoff.acquiring.sdk.models.AsdkState import ru.tinkoff.acquiring.sdk.models.GooglePayParams import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions -import ru.tinkoff.acquiring.sdk.models.paysources.SbpPay import ru.tinkoff.acquiring.sdk.payment.* -import ru.tinkoff.acquiring.sdk.redesign.payment.ui.PaymentByCardResult +import ru.tinkoff.acquiring.sdk.redesign.payment.ui.PaymentByCard import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.SbpNoBanksStubActivity import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.SbpResult -import ru.tinkoff.acquiring.sdk.redesign.sbp.util.SbpHelper import ru.tinkoff.acquiring.sdk.threeds.ThreeDsHelper import ru.tinkoff.acquiring.sdk.utils.GooglePayHelper import ru.tinkoff.acquiring.sdk.utils.Money @@ -56,6 +54,7 @@ import ru.tinkoff.acquiring.yandexpay.models.YandexPayData import ru.tinkoff.acquiring.yandexpay.models.enableYandexPay import ru.tinkoff.acquiring.yandexpay.models.mapYandexPayData import java.util.* +import kotlin.collections.ArrayList import kotlin.math.abs /** @@ -90,15 +89,15 @@ open class PayableActivity : AppCompatActivity() { is SbpResult.Canceled -> toast("SBP canceled") } } - private val byCardPayment = registerForActivityResult(PaymentByCardResult.Contract) { result -> + private val byCardPayment = registerForActivityResult(PaymentByCard.Contract) { result -> when (result) { - is PaymentByCardResult.Success -> { + is PaymentByCard.Success -> { toast("Payment Success") } - is PaymentByCardResult.Error -> toast( + is PaymentByCard.Error -> toast( result.error.message ?: getString(R.string.error_title) ) - is PaymentByCardResult.Canceled -> toast("Payment canceled") + is PaymentByCard.Canceled -> toast("Payment canceled") } } @@ -178,7 +177,7 @@ open class PayableActivity : AppCompatActivity() { publicKey = TerminalsManager.selectedTerminal.publicKey ) } - byCardPayment.launch(options) + byCardPayment.launch(PaymentByCard.StartData(options, ArrayList())) } protected fun openDynamicQrScreen() { diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt index 8476f08c..12350dc3 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt @@ -545,6 +545,30 @@ class TinkoffAcquiring( } } + object ChoseCard { + + sealed class Result + class Success(val card: CardData) : Result() + class Canceled : Result() + class Error(val error: Throwable) : Result() + + object Contract : ActivityResultContract() { + + override fun createIntent(context: Context, input: SavedCardsOptions): Intent = + BaseAcquiringActivity.createIntent(context, input.apply { + setTerminalParams(terminalKey, publicKey) + }, CardsListActivity::class.java) + + override fun parseResult(resultCode: Int, intent: Intent?): Result = when (resultCode) { + AppCompatActivity.RESULT_OK -> Success( + intent?.getSerializableExtra(EXTRA_CHOSEN_CARD) as CardData + ) + RESULT_ERROR -> Error(intent!!.getSerializableExtra(EXTRA_ERROR)!! as Throwable) + else -> Canceled() + } + } + } + companion object { const val RESULT_ERROR = 500 @@ -554,5 +578,6 @@ class TinkoffAcquiring( const val EXTRA_REBILL_ID = "extra_rebill_id" const val EXTRA_CARD_LIST_CHANGED = "extra_cards_changed" + const val EXTRA_CHOSEN_CARD = "extra_chosen_card" } } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt index cb0f93f6..2b9596f2 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt @@ -10,7 +10,6 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.fragment.app.Fragment -import kotlinx.android.synthetic.main.acq_fragment_card_data_input.* import ru.tinkoff.acquiring.sdk.R import ru.tinkoff.acquiring.sdk.cardscanners.CameraCardScanner import ru.tinkoff.acquiring.sdk.cardscanners.CardScanner @@ -48,7 +47,7 @@ internal class CardDataInputFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - with(card_number_input) { + with(cardNumberInput) { BaubleCardLogo().attach(this) BaubleClearButton().attach(this) val cardNumberFormatter = CardNumberFormatter().also { @@ -71,7 +70,7 @@ internal class CardDataInputFragment : Fragment() { errorHighlighted = true } else if (cardNumberFormatter.isSingleInsert && shouldAutoSwitchFromCardNumber(cardNumber, paymentSystem)) { - expiry_date_input.requestViewFocus() + cardNumberInput.requestViewFocus() } } @@ -79,7 +78,7 @@ internal class CardDataInputFragment : Fragment() { } } - with(expiry_date_input) { + with(expiryDateInput) { BaubleClearButton().attach(this) MaskFormatWatcher(createExpiryDateMask()).installOn(editText) @@ -89,7 +88,7 @@ internal class CardDataInputFragment : Fragment() { val expiryDate = expiryDate if (expiryDate.length >= EXPIRY_DATE_MASK.length) { if (CardValidator.validateExpireDate(expiryDate, false)) { - cvc_input.requestViewFocus() + cvcInput.requestViewFocus() } else { errorHighlighted = true } @@ -99,7 +98,7 @@ internal class CardDataInputFragment : Fragment() { } } - with(cvc_input) { + with(cvcInput) { BaubleClearButton().attach(this) transformationMethod = PasswordTransformationMethod() MaskFormatWatcher(createCvcMask()).installOn(editText) @@ -110,7 +109,7 @@ internal class CardDataInputFragment : Fragment() { val cvc = cvc if (cvc.length >= EXPIRY_DATE_MASK.length) { if (CardValidator.validateSecurityCode(cvc)) { - cvc_input.clearViewFocus() + cvcInput.clearViewFocus() if (validate()) { onComplete?.invoke(this@CardDataInputFragment) } @@ -162,15 +161,15 @@ internal class CardDataInputFragment : Fragment() { fun validate(): Boolean { var result = true if (CardValidator.validateCardNumber(cardNumber)) { - card_number_input.errorHighlighted = true + cardNumberInput.errorHighlighted = true result = false } if (CardValidator.validateExpireDate(expiryDate, false)) { - expiry_date_input.errorHighlighted = true + expiryDateInput.errorHighlighted = true result = false } if (CardValidator.validateSecurityCode(cvc)) { - cvc_input.errorHighlighted = true + cvcInput.errorHighlighted = true result = false } return result diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CvcComponent.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CvcComponent.kt new file mode 100644 index 00000000..8abd4004 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CvcComponent.kt @@ -0,0 +1,74 @@ +package ru.tinkoff.acquiring.sdk.redesign.common.carddatainput + +import android.text.method.PasswordTransformationMethod +import android.view.ViewGroup +import ru.tinkoff.acquiring.sdk.R +import ru.tinkoff.acquiring.sdk.smartfield.AcqTextFieldView +import ru.tinkoff.acquiring.sdk.smartfield.BaubleClearButton +import ru.tinkoff.acquiring.sdk.ui.component.UiComponent +import ru.tinkoff.acquiring.sdk.ui.customview.editcard.validators.CardValidator +import ru.tinkoff.acquiring.sdk.utils.SimpleTextWatcher.Companion.afterTextChanged +import ru.tinkoff.decoro.MaskImpl +import ru.tinkoff.decoro.parser.UnderscoreDigitSlotsParser +import ru.tinkoff.decoro.watchers.MaskFormatWatcher + +/** + * Created by i.golovachev + */ +class CvcComponent( + val root: ViewGroup, + val onInputComplete: (String) -> Unit = {}, + val onDataChange: (Boolean) -> Unit = {} +) : UiComponent { + + private val cvcInput: AcqTextFieldView = root.findViewById(R.id.card_number_input) + val cvc get() = cvcInput.text.orEmpty() + + init { + with(cvcInput) { + transformationMethod = PasswordTransformationMethod() + MaskFormatWatcher(createCvcMask()).installOn(editText) + + editText.afterTextChanged { + errorHighlighted = false + + val cvc = cvc + if (cvc.length >= CVC_MASK.length) { + if (CardValidator.validateSecurityCode(cvc)) { + cvcInput.clearViewFocus() + if (validate()) { + onInputComplete(cvc) + } + } else { + errorHighlighted = true + } + } + + onDataChange(validate()) + } + } + } + + fun validate(): Boolean { + var result = true + if (CardValidator.validateSecurityCode(cvc)) { + cvcInput.errorHighlighted = true + result = false + } + return result + } + + override fun render(state: String?) { + cvcInput.text = state + } + + fun requestViewFocus() { + cvcInput.requestApplyInsets() + } + + companion object { + const val CVC_MASK = "___" + fun createCvcMask(): MaskImpl = MaskImpl + .createTerminated(UnderscoreDigitSlotsParser().parseSlots(CVC_MASK)) + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/emailinput/EmailInputFragment.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/emailinput/EmailInputFragment.kt new file mode 100644 index 00000000..453c4029 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/emailinput/EmailInputFragment.kt @@ -0,0 +1,75 @@ +package ru.tinkoff.acquiring.sdk.redesign.common.emailinput + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import ru.tinkoff.acquiring.sdk.R +import ru.tinkoff.acquiring.sdk.smartfield.AcqTextFieldView +import ru.tinkoff.acquiring.sdk.smartfield.BaubleClearButton +import ru.tinkoff.acquiring.sdk.utils.SimpleTextWatcher +import ru.tinkoff.acquiring.sdk.utils.getParent +import ru.tinkoff.acquiring.sdk.utils.lazyView +import java.util.regex.Pattern + +/** + * Created by i.golovachev + */ +internal class EmailInputFragment : Fragment() { + + val emailInput: AcqTextFieldView by lazyView(R.id.email_input) + val emailValue get() = emailInput.text.orEmpty() + + private val textWatcher = SimpleTextWatcher.after { + onDataChanged() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? = + inflater.inflate(R.layout.acq_fragment_email_input, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + with(emailInput) { + BaubleClearButton().attach(this) + arguments?.getString(EMAIL_ARG)?.let { editText.setText(it) } + editText.addTextChangedListener(textWatcher) + } + } + + fun withArguments(email: String?): EmailInputFragment = apply { + arguments = bundleOf(EMAIL_ARG to email) + } + + fun isValid(): Boolean = EmailValidator.validate(emailValue) + + private fun onDataChanged() { + getParent()?.onEmailDataChanged(isValid()) + } + + fun interface OnEmailDataChanged { + fun onEmailDataChanged(isValid: Boolean) + } + + companion object { + private const val EMAIL_ARG = "EMAIL_ARG" + fun getInstance(email: String?) = EmailInputFragment().withArguments(email) + } +} + +object EmailValidator { + private const val EMAIL_REGEX = ".+\\@.+\\..+" + private val pattern = Pattern.compile(EMAIL_REGEX) + + fun validate(text: String?): Boolean { + if (text == null) return false + + return text.isNotBlank() && pattern.matcher(text).matches() + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusFormExt.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusFormExt.kt index e0953a03..5cfefad8 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusFormExt.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusFormExt.kt @@ -2,10 +2,11 @@ package ru.tinkoff.acquiring.sdk.redesign.dialog import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager import ru.tinkoff.acquiring.sdk.R interface OnPaymentSheetCloseListener { - fun onClose(state: PaymentSheetStatus) + fun onClose(state: PaymentStatusSheetState) } fun T.createPaymentSheetWrapper(): PaymentStatusSheet where T : FragmentActivity, T : OnPaymentSheetCloseListener { @@ -16,32 +17,43 @@ fun T.createPaymentSheetWrapper(): PaymentStatusSheet where T : Fragment, T return PaymentStatusSheet() } -sealed class PaymentSheetStatus( +fun PaymentStatusSheet.showIfNeed( + fragmentManager: FragmentManager, + tag: String? = null +): PaymentStatusSheet { + if (isAdded.not()) { + show(fragmentManager, tag) + } + + return this +} + +sealed class PaymentStatusSheetState( open val title: Int?, open val subtitle: Int? = null, open val mainButton: Int? = null, open val secondButton: Int? = null ) { - object NotYet : PaymentSheetStatus(null) + object NotYet : PaymentStatusSheetState(null) data class Progress( override val title: Int, override val subtitle: Int? = null, override val secondButton: Int? = null - ) : PaymentSheetStatus(title, subtitle, null, secondButton) + ) : PaymentStatusSheetState(title, subtitle, null, secondButton) class Error( title: Int, subtitle: Int? = null, mainButton: Int? = null, secondButton: Int? = null, val throwable: Throwable - ) : PaymentSheetStatus(title, subtitle, mainButton, secondButton) + ) : PaymentStatusSheetState(title, subtitle, mainButton, secondButton) class Success( title: Int = R.string.acq_commonsheet_paid_title, subtitle: Int? = null, mainButton: Int? = R.string.acq_commonsheet_clear_primarybutton, val resultData: java.io.Serializable - ) : PaymentSheetStatus(title, subtitle, mainButton) + ) : PaymentStatusSheetState(title, subtitle, mainButton) - object Hide : PaymentSheetStatus(null) + object Hide : PaymentStatusSheetState(null) } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusSheet.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusSheet.kt index 31c49fe0..735e3b93 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusSheet.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusSheet.kt @@ -32,7 +32,7 @@ class PaymentStatusSheet internal constructor(): BottomSheetDialogFragment() { } } - var state: PaymentSheetStatus? = null + var state: PaymentStatusSheetState? = null set(value) { field = value if (value != null && isResumed) { @@ -110,7 +110,7 @@ class PaymentStatusSheet internal constructor(): BottomSheetDialogFragment() { this.isCancelable = isCancelable } - private fun showState(state: PaymentSheetStatus) { + private fun showState(state: PaymentStatusSheetState) { set( icon = defineIcon(state), title = state.title, @@ -123,11 +123,11 @@ class PaymentStatusSheet internal constructor(): BottomSheetDialogFragment() { this.secondButton.setOnClickListener { onCloseListener.onClose(state) } } - private fun defineIcon(state: PaymentSheetStatus) = when (state) { - is PaymentSheetStatus.Error -> R.drawable.acq_ic_cross_circle - is PaymentSheetStatus.NotYet -> null - is PaymentSheetStatus.Progress -> null - is PaymentSheetStatus.Hide -> null - is PaymentSheetStatus.Success -> R.drawable.acq_ic_check_circle_positive + private fun defineIcon(state: PaymentStatusSheetState) = when (state) { + is PaymentStatusSheetState.Error -> R.drawable.acq_ic_cross_circle + is PaymentStatusSheetState.NotYet -> null + is PaymentStatusSheetState.Progress -> null + is PaymentStatusSheetState.Hide -> null + is PaymentStatusSheetState.Success -> R.drawable.acq_ic_check_circle_positive } } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/model/CardChosenModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/model/CardChosenModel.kt new file mode 100644 index 00000000..4e19f79c --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/model/CardChosenModel.kt @@ -0,0 +1,14 @@ +package ru.tinkoff.acquiring.sdk.redesign.payment.model + +import ru.tinkoff.acquiring.sdk.models.Card + +data class CardChosenModel( + private val card: Card, + val bankName: String? +) { + val id = card.cardId + + val pan: String? = card.pan + + val tail = card.pan?.takeLast(4) +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/ChosenCardComponent.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/ChosenCardComponent.kt new file mode 100644 index 00000000..09d04f49 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/ChosenCardComponent.kt @@ -0,0 +1,36 @@ +package ru.tinkoff.acquiring.sdk.redesign.payment.ui + +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import ru.tinkoff.acquiring.sdk.R +import ru.tinkoff.acquiring.sdk.redesign.common.carddatainput.CvcComponent +import ru.tinkoff.acquiring.sdk.redesign.payment.model.CardChosenModel +import ru.tinkoff.acquiring.sdk.ui.component.UiComponent +import ru.tinkoff.acquiring.sdk.viewmodel.CardLogoProvider + +/** + * Created by i.golovachev + */ +internal class ChosenCardComponent( + private val root: ViewGroup, + private val onChangeCard: (CardChosenModel) -> Unit = {}, + private val onCvcCompleted: (String) -> Unit = {} +) : UiComponent { + + private val cardLogo: ImageView = root.findViewById(R.id.acq_card_choosen_item_logo) + private val cardName: TextView = root.findViewById(R.id.acq_card_choosen_item) + private val cardChange: TextView = root.findViewById(R.id.acq_card_change) + private val cardCvc: CvcComponent = CvcComponent( + root.findViewById(R.id.cvc_container), onInputComplete = onCvcCompleted + ) + + override fun render(state: CardChosenModel) = with(state) { + cardLogo.setImageResource(CardLogoProvider.getCardLogo(pan)) + cardName.text = root.context.getString( + R.string.acq_cardlist_bankname, bankName, tail + ) + cardChange.setOnClickListener { onChangeCard(state) } + } + +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCard.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCard.kt new file mode 100644 index 00000000..b0804bf4 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCard.kt @@ -0,0 +1,69 @@ +package ru.tinkoff.acquiring.sdk.redesign.payment.ui + +import android.content.Context +import android.content.Intent +import android.os.Parcelable +import androidx.activity.result.contract.ActivityResultContract +import androidx.appcompat.app.AppCompatActivity +import kotlinx.android.parcel.Parcelize +import ru.tinkoff.acquiring.sdk.TinkoffAcquiring +import ru.tinkoff.acquiring.sdk.models.Card +import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions +import ru.tinkoff.acquiring.sdk.models.result.PaymentResult + +object PaymentByCard { + + sealed class Result + class Success( + val paymentId: Long? = null, + val cardId: String? = null, + val rebillId: String? = null + ) : Result() + + object Canceled : Result() + class Error(val error: Throwable) : Result() + + + @Parcelize + class StartData( + val paymentOptions: PaymentOptions, + val list: ArrayList + ): Parcelable + + object Contract : ActivityResultContract() { + + internal const val EXTRA_SAVED_CARDS = "extra_saved_cards" + + internal fun createSuccessIntent(paymentResult: PaymentResult): Intent { + val intent = Intent() + intent.putExtra(TinkoffAcquiring.EXTRA_PAYMENT_ID, paymentResult.paymentId) + intent.putExtra(TinkoffAcquiring.EXTRA_CARD_ID, paymentResult.cardId) + intent.putExtra(TinkoffAcquiring.EXTRA_REBILL_ID, paymentResult.rebillId) + return intent + } + + internal fun createFailedIntent(throwable: Throwable): Intent { + val intent = Intent() + intent.putExtra(TinkoffAcquiring.EXTRA_ERROR, throwable) + return intent + } + + override fun createIntent(context: Context, startData: StartData): Intent = + Intent(context, PaymentByCardActivity::class.java).apply { + putExtra(EXTRA_SAVED_CARDS, startData) + } + + override fun parseResult(resultCode: Int, intent: Intent?): Result = when (resultCode) { + AppCompatActivity.RESULT_OK -> { + val _intent = intent!! + Success( + _intent.getLongExtra(TinkoffAcquiring.EXTRA_PAYMENT_ID, -1), + _intent.getStringExtra(TinkoffAcquiring.EXTRA_CARD_ID), + _intent.getStringExtra(TinkoffAcquiring.EXTRA_REBILL_ID), + ) + } + TinkoffAcquiring.RESULT_ERROR -> Error(intent!!.getSerializableExtra(TinkoffAcquiring.EXTRA_ERROR)!! as Throwable) + else -> Canceled + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardActivity.kt index 6a9cca60..b31a849e 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardActivity.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardActivity.kt @@ -1,70 +1,112 @@ package ru.tinkoff.acquiring.sdk.redesign.payment.ui import android.app.Activity -import android.content.Context import android.content.Intent import android.os.Bundle -import androidx.activity.result.contract.ActivityResultContract import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.SwitchCompat +import androidx.cardview.widget.CardView +import androidx.core.view.isVisible +import androidx.fragment.app.FragmentContainerView +import androidx.fragment.app.commit import androidx.lifecycle.* -import kotlinx.coroutines.launch import ru.tinkoff.acquiring.sdk.R import ru.tinkoff.acquiring.sdk.TinkoffAcquiring import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions -import ru.tinkoff.acquiring.sdk.models.paysources.CardData +import ru.tinkoff.acquiring.sdk.models.options.screen.SavedCardsOptions import ru.tinkoff.acquiring.sdk.models.result.AsdkResult import ru.tinkoff.acquiring.sdk.models.result.PaymentResult import ru.tinkoff.acquiring.sdk.payment.PaymentByCardState import ru.tinkoff.acquiring.sdk.redesign.common.carddatainput.CardDataInputFragment -import ru.tinkoff.acquiring.sdk.redesign.dialog.OnPaymentSheetCloseListener -import ru.tinkoff.acquiring.sdk.redesign.dialog.PaymentSheetStatus -import ru.tinkoff.acquiring.sdk.redesign.dialog.createPaymentSheetWrapper +import ru.tinkoff.acquiring.sdk.redesign.common.emailinput.EmailInputFragment +import ru.tinkoff.acquiring.sdk.redesign.dialog.* +import ru.tinkoff.acquiring.sdk.redesign.payment.ui.PaymentByCard.Contract.EXTRA_SAVED_CARDS +import ru.tinkoff.acquiring.sdk.redesign.payment.ui.PaymentByCard.Contract.createSuccessIntent import ru.tinkoff.acquiring.sdk.threeds.ThreeDsHelper import ru.tinkoff.acquiring.sdk.ui.activities.TransparentActivity import ru.tinkoff.acquiring.sdk.ui.customview.LoaderButton +import ru.tinkoff.acquiring.sdk.utils.* +import ru.tinkoff.acquiring.sdk.utils.lazyUnsafe import ru.tinkoff.acquiring.sdk.utils.lazyView -import ru.tinkoff.acquiring.sdk.utils.toBundle /** * Created by i.golovachev */ internal class PaymentByCardActivity : AppCompatActivity(), - CardDataInputFragment.OnCardDataChanged, OnPaymentSheetCloseListener { + CardDataInputFragment.OnCardDataChanged, + EmailInputFragment.OnEmailDataChanged, + OnPaymentSheetCloseListener { + + private val startData: PaymentByCard.StartData by lazyUnsafe { + intent.getParcelableExtra(EXTRA_SAVED_CARDS)!! + } + + private val savedCardOptions: SavedCardsOptions by lazyUnsafe { + SavedCardsOptions().apply { + setTerminalParams( + startData.paymentOptions.terminalKey, + startData.paymentOptions.publicKey + ) + } + } + + private val cardDataInputContainer: FragmentContainerView by lazyView(R.id.fragment_card_data_input) private val cardDataInput get() = supportFragmentManager.findFragmentById(R.id.fragment_card_data_input) as CardDataInputFragment + private val chosenCardContainer: CardView by lazyView(R.id.acq_chosen_card) + + private val chosenCardComponent: ChosenCardComponent by lazyUnsafe { + ChosenCardComponent( + chosenCardContainer, + onChangeCard = { onChangeCard() }, + onCvcCompleted = viewModel::setCvc + ) + } + + private val emailInput: EmailInputFragment by lazyUnsafe { + EmailInputFragment.getInstance(startData.paymentOptions.customer.email) + } + + private val emailInputContainer: FragmentContainerView by lazyView(R.id.fragment_email_input) + + private val sendReceiptSwitch: SwitchCompat by lazyView(R.id.acq_send_receipt_switch) + private val payButton: LoaderButton by lazyView(R.id.acq_pay_btn) private val viewModel: PaymentByCardViewModel by viewModels { PaymentByCardViewModel.factory() } private val statusSheetStatus = createPaymentSheetWrapper() - private var onPaymentInternal: OnPaymentSheetCloseListener? = null + private val savedCards = + registerForActivityResult(TinkoffAcquiring.ChoseCard.Contract) { result -> + when (result) { + is TinkoffAcquiring.ChoseCard.Success -> result.card + else -> Unit + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.acq_payment_by_card_new_activity) initToolbar() + initViews() - lifecycleScope.launchWhenCreated { buttonState() } - lifecycleScope.launch { processState() } - payButton.setOnClickListener { - viewModel.pay() - } + lifecycleScope.launchWhenCreated { processState() } + lifecycleScope.launchWhenCreated { uiState() } } override fun onResume() { super.onResume() if (statusSheetStatus.state != null - && statusSheetStatus.state != PaymentSheetStatus.NotYet - && statusSheetStatus.state != PaymentSheetStatus.Hide + && statusSheetStatus.state != PaymentStatusSheetState.NotYet + && statusSheetStatus.state != PaymentStatusSheetState.Hide + && statusSheetStatus.isAdded.not() ) { - if (statusSheetStatus.isAdded.not()) { - statusSheetStatus.showNow(supportFragmentManager, null) - } + statusSheetStatus.showNow(supportFragmentManager, null) } } @@ -77,19 +119,25 @@ internal class PaymentByCardActivity : AppCompatActivity(), ) } - override fun onClose(state: PaymentSheetStatus) { + override fun onEmailDataChanged(isValid: Boolean) { + viewModel.setEmail(email = emailInput.emailValue, isValidEmail = isValid) + } - if (onPaymentInternal != null) { - onPaymentInternal?.onClose(state) - } else { - if (state is PaymentSheetStatus.Error) { - finishWithError(state.throwable) - } else { + override fun onClose(state: PaymentStatusSheetState) { + when (state) { + is PaymentStatusSheetState.Error -> statusSheetStatus.dismissAllowingStateLoss() + is PaymentStatusSheetState.Success -> finishWithSuccess(state.resultData as PaymentResult) + else -> { + setResult(RESULT_CANCELED) finish() } } } + private fun onChangeCard() { + savedCards.launch(savedCardOptions) + } + override fun onSupportNavigateUp(): Boolean { onBackPressed() return true @@ -108,9 +156,28 @@ internal class PaymentByCardActivity : AppCompatActivity(), supportActionBar?.setTitle(R.string.acq_banklist_title) } - private suspend fun buttonState() { + private fun initViews() { + + supportFragmentManager.commit { + replace(emailInputContainer.id, emailInput) + } + + sendReceiptSwitch.setOnCheckedChangeListener { _, isChecked -> + viewModel.sendReceiptChange(isChecked) + } + + payButton.setOnClickListener { + viewModel.pay() + } + } + + private suspend fun uiState() { viewModel.state.collect { - payButton.text = it.amount + chosenCardContainer.isVisible = it.hasSavedCard + cardDataInputContainer.isVisible = it.hasSavedCard.not() + emailInputContainer.isVisible = it.sendReceipt + sendReceiptSwitch.isChecked = it.sendReceipt + payButton.text = getString(R.string.acq_cardpay_pay, it.amount) payButton.isEnabled = it.buttonEnabled } } @@ -122,15 +189,22 @@ internal class PaymentByCardActivity : AppCompatActivity(), when (it) { is PaymentByCardState.Created -> Unit is PaymentByCardState.Error -> { - statusSheetStatus.show(supportFragmentManager, null) - statusSheetStatus.state = PaymentSheetStatus.Error( - title = R.string.acq_commonsheet_failed_title, - mainButton = R.string.acq_commonsheet_failed_primary_button, - throwable = it.throwable - ) + statusSheetStatus.showIfNeed(supportFragmentManager).state = + PaymentStatusSheetState.Error( + title = R.string.acq_commonsheet_failed_title, + mainButton = R.string.acq_commonsheet_failed_primary_button, + throwable = it.throwable + ) } is PaymentByCardState.Started -> Unit - is PaymentByCardState.Success -> Unit + is PaymentByCardState.Success -> { + statusSheetStatus.showIfNeed(supportFragmentManager).state = + PaymentStatusSheetState.Success( + title = R.string.acq_commonsheet_paid_title, + mainButton = R.string.acq_commonsheet_clear_primarybutton, + resultData = PaymentResult(it.paymentId, it.cardId, it.rebillId) + ) + } is PaymentByCardState.ThreeDsUiNeeded -> try { ThreeDsHelper.Launch.launchBrowserBased( this, @@ -139,12 +213,12 @@ internal class PaymentByCardActivity : AppCompatActivity(), it.threeDsState.data, ) } catch (e: Throwable) { - statusSheetStatus.show(supportFragmentManager, null) - statusSheetStatus.state = PaymentSheetStatus.Error( - title = R.string.acq_commonsheet_failed_title, - mainButton = R.string.acq_commonsheet_failed_primary_button, - throwable = e - ) + statusSheetStatus.showIfNeed(supportFragmentManager).state = + PaymentStatusSheetState.Error( + title = R.string.acq_commonsheet_failed_title, + mainButton = R.string.acq_commonsheet_failed_primary_button, + throwable = e + ) } } } @@ -153,13 +227,13 @@ internal class PaymentByCardActivity : AppCompatActivity(), override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (requestCode == TransparentActivity.THREE_DS_REQUEST_CODE) { if (resultCode == Activity.RESULT_OK && data != null) { - statusSheetStatus.state = PaymentSheetStatus.Success( + statusSheetStatus.state = PaymentStatusSheetState.Success( title = R.string.acq_commonsheet_paid_title, mainButton = R.string.acq_commonsheet_clear_primarybutton, resultData = data.getSerializableExtra(ThreeDsHelper.Launch.RESULT_DATA) as AsdkResult ) } else if (resultCode == ThreeDsHelper.Launch.RESULT_ERROR) { - statusSheetStatus.state = PaymentSheetStatus.Error( + statusSheetStatus.state = PaymentStatusSheetState.Error( title = R.string.acq_commonsheet_failed_title, mainButton = R.string.acq_commonsheet_failed_primary_button, throwable = data?.getSerializableExtra(ThreeDsHelper.Launch.ERROR_DATA) as Throwable @@ -173,47 +247,8 @@ internal class PaymentByCardActivity : AppCompatActivity(), } } - private fun finishWithError(throwable: Throwable) { - val intent = Intent() - intent.putExtra(TinkoffAcquiring.EXTRA_ERROR, throwable) - setResult(TinkoffAcquiring.RESULT_ERROR, intent) + private fun finishWithSuccess(result: PaymentResult) { + setResult(RESULT_OK, createSuccessIntent(result)) finish() } -} - -object PaymentByCardResult { - - private const val EXTRA_ASDK_RESULT = "asdk_result" - - sealed class Result - class Success( - val paymentId: Long? = null, - val cardId: String? = null, - val rebillId: String? = null - ) : Result() - - class Canceled : Result() - class Error(val error: Throwable) : Result() - - - object Contract : ActivityResultContract() { - - override fun createIntent(context: Context, paymentOptions: PaymentOptions): Intent = - Intent(context, PaymentByCardActivity::class.java).apply { - putExtras(paymentOptions.toBundle()) - } - - override fun parseResult(resultCode: Int, intent: Intent?): Result = when (resultCode) { - AppCompatActivity.RESULT_OK -> { - val result = intent!!.getSerializableExtra(EXTRA_ASDK_RESULT) as PaymentResult - Success( - result.paymentId, - result.cardId, - result.rebillId - ) - } - TinkoffAcquiring.RESULT_ERROR -> Error(intent!!.getSerializableExtra(TinkoffAcquiring.EXTRA_ERROR)!! as Throwable) - else -> Canceled() - } - } } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardViewModel.kt index b37187cd..7717cc1b 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardViewModel.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardViewModel.kt @@ -10,16 +10,27 @@ import kotlinx.coroutines.flow.update import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions import ru.tinkoff.acquiring.sdk.models.paysources.CardData import ru.tinkoff.acquiring.sdk.payment.PaymentByCardProcess -import ru.tinkoff.acquiring.sdk.utils.getExtra internal class PaymentByCardViewModel( private val savedStateHandle: SavedStateHandle, private val paymentByCardProcess: PaymentByCardProcess ) : ViewModel() { + + private val startData = + savedStateHandle.get(PaymentByCard.Contract.EXTRA_SAVED_CARDS)!! + val paymentProcessState = paymentByCardProcess.state val state: MutableStateFlow = - MutableStateFlow(State(paymentOptions = savedStateHandle.getExtra())) + MutableStateFlow( + State( + isValidEmail = startData.paymentOptions.customer.email.isNullOrBlank().not(), + sendReceipt = startData.paymentOptions.customer.email.isNullOrBlank().not(), + email = startData.paymentOptions.customer.email, + paymentOptions = startData.paymentOptions, + hasSavedCard = startData.list.isNotEmpty() + ) + ) fun setCardDate( cardNumber: String? = null, @@ -35,11 +46,9 @@ internal class PaymentByCardViewModel( ) } - fun rememberCardChange(isSelect: Boolean) = state.update { - it.copy(rememberCard = isSelect) - } + fun setCvc(cvc: String) = state.update { it.copy(cvc = cvc) } - fun saveEmailChange(isSelect: Boolean) = state.update { + fun sendReceiptChange(isSelect: Boolean) = state.update { it.copy(sendReceipt = isSelect) } @@ -48,7 +57,9 @@ internal class PaymentByCardViewModel( } fun pay() { - paymentByCardProcess.start(state.value.cardData, state.value.paymentOptions) + val _state = state.value + val emailForPayment = if (_state.sendReceipt) _state.email else null + paymentByCardProcess.start(_state.cardData, _state.paymentOptions, emailForPayment) } fun cancelPayment() { @@ -60,10 +71,11 @@ internal class PaymentByCardViewModel( private val cvc: String? = null, private val dateExpired: String? = null, private val isValidCardData: Boolean = false, - private val rememberCard: Boolean = false, - private val email: String? = null, private val isValidEmail: Boolean = false, - private val sendReceipt: Boolean = false, + + val hasSavedCard: Boolean = false, + val sendReceipt: Boolean = false, + val email: String? = null, val paymentOptions: PaymentOptions, ) { @@ -86,7 +98,6 @@ internal class PaymentByCardViewModel( validate() } } - } companion object { diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentActivity.kt index 11bb2da3..4a612174 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentActivity.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentActivity.kt @@ -19,7 +19,6 @@ package ru.tinkoff.acquiring.sdk.redesign.sbp.ui import android.annotation.SuppressLint import android.content.Context import android.content.Intent -import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -115,14 +114,14 @@ internal class SbpPaymentActivity : AppCompatActivity(), OnPaymentSheetCloseList finish() } - override fun onClose(status: PaymentSheetStatus) { + override fun onClose(status: PaymentStatusSheetState) { when (status) { - is PaymentSheetStatus.Error -> finishWithError(status.throwable) - is PaymentSheetStatus.Progress -> { + is PaymentStatusSheetState.Error -> finishWithError(status.throwable) + is PaymentStatusSheetState.Progress -> { viewModel.cancelPayment() statusFragment.dismiss() } - is PaymentSheetStatus.Success -> finishWithResult(status.resultData as Long) + is PaymentStatusSheetState.Success -> finishWithResult(status.resultData as Long) else -> Unit } } @@ -182,10 +181,10 @@ internal class SbpPaymentActivity : AppCompatActivity(), OnPaymentSheetCloseList viewModel.paymentStateFlow.collect { statusFragment.state = it when (it) { - is PaymentSheetStatus.Hide -> if (statusFragment.isAdded) { + is PaymentStatusSheetState.Hide -> if (statusFragment.isAdded) { statusFragment.dismiss() } - is PaymentSheetStatus.NotYet -> Unit + is PaymentStatusSheetState.NotYet -> Unit else -> if (statusFragment.isAdded.not()) { statusFragment.show(supportFragmentManager, null) } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentViewModel.kt index 76b3aa2c..48a92eca 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentViewModel.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentViewModel.kt @@ -6,7 +6,7 @@ import androidx.lifecycle.viewmodel.viewModelFactory import kotlinx.coroutines.flow.MutableStateFlow import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions import ru.tinkoff.acquiring.sdk.payment.SbpPaymentProcess -import ru.tinkoff.acquiring.sdk.redesign.dialog.PaymentSheetStatus +import ru.tinkoff.acquiring.sdk.redesign.dialog.PaymentStatusSheetState import ru.tinkoff.acquiring.sdk.redesign.sbp.util.SbpStateMapper import ru.tinkoff.acquiring.sdk.utils.ConnectionChecker import ru.tinkoff.acquiring.sdk.utils.CoroutineManager @@ -20,7 +20,7 @@ internal class SbpPaymentViewModel( ) : ViewModel() { val stateUiFlow = MutableStateFlow(SpbBankListState.Shimmer) - val paymentStateFlow = MutableStateFlow(PaymentSheetStatus.NotYet) + val paymentStateFlow = MutableStateFlow(PaymentStatusSheetState.NotYet) init { manager.launchOnBackground { diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpStateMapper.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpStateMapper.kt index 10d4ecdd..8bc5b048 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpStateMapper.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpStateMapper.kt @@ -4,7 +4,7 @@ import ru.tinkoff.acquiring.sdk.R import ru.tinkoff.acquiring.sdk.exceptions.AcquiringSdkTimeoutException import ru.tinkoff.acquiring.sdk.models.enums.ResponseStatus import ru.tinkoff.acquiring.sdk.payment.SbpPaymentState -import ru.tinkoff.acquiring.sdk.redesign.dialog.PaymentSheetStatus +import ru.tinkoff.acquiring.sdk.redesign.dialog.PaymentStatusSheetState import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.SpbBankListState /** @@ -23,10 +23,10 @@ class SbpStateMapper { else -> null } - fun mapStatusForm(it: SbpPaymentState): PaymentSheetStatus? { + fun mapStatusForm(it: SbpPaymentState): PaymentStatusSheetState? { return when (it) { is SbpPaymentState.LeaveOnBankApp -> { - PaymentSheetStatus.Progress( + PaymentStatusSheetState.Progress( title = R.string.acq_commonsheet_payment_waiting_title, secondButton = R.string.acq_commonsheet_payment_waiting_flat_button ) @@ -34,36 +34,36 @@ class SbpStateMapper { is SbpPaymentState.CheckingStatus -> { val status = it.status if (status == ResponseStatus.FORM_SHOWED) { - PaymentSheetStatus.Progress( + PaymentStatusSheetState.Progress( title = R.string.acq_commonsheet_payment_waiting_title, secondButton = R.string.acq_commonsheet_payment_waiting_flat_button ) } else { - PaymentSheetStatus.Progress( + PaymentStatusSheetState.Progress( title = R.string.acq_commonsheet_processing_title, subtitle = R.string.acq_commonsheet_processing_description ) } } is SbpPaymentState.Success -> - PaymentSheetStatus.Success(resultData = it.paymentId) + PaymentStatusSheetState.Success(resultData = it.paymentId) is SbpPaymentState.PaymentFailed -> if (it.throwable is AcquiringSdkTimeoutException) { - PaymentSheetStatus.Error( + PaymentStatusSheetState.Error( title = R.string.acq_commonsheet_timeout_failed_title, subtitle = R.string.acq_commonsheet_timeout_failed_description, throwable = it.throwable, secondButton = R.string.acq_commonsheet_timeout_failed_flat_button ) } else { - PaymentSheetStatus.Error( + PaymentStatusSheetState.Error( title = R.string.acq_commonsheet_failed_title, subtitle = R.string.acq_commonsheet_failed_description, throwable = it.throwable, mainButton = R.string.acq_commonsheet_failed_primary_button ) } - is SbpPaymentState.Stopped -> PaymentSheetStatus.Hide + is SbpPaymentState.Stopped -> PaymentStatusSheetState.Hide else -> null } } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/component/UiComponent.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/component/UiComponent.kt new file mode 100644 index 00000000..d0c0009c --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/ui/component/UiComponent.kt @@ -0,0 +1,20 @@ +package ru.tinkoff.acquiring.sdk.ui.component + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +/** + * Created by i.golovachev + */ +// что бы не использовать фрагменты +interface UiComponent { + + fun render(state: State) +} + +fun UiComponent.bindKtx(coroutineScope: CoroutineScope, flow: Flow){ + coroutineScope.launch { flow.collect(::render) } +} + +val CallbackStub = {} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/IntentExt.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/IntentExt.kt index 01f3ec8c..cbe7fd6f 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/IntentExt.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/IntentExt.kt @@ -14,7 +14,11 @@ private const val EXTRA_OPTIONS = "options" fun bundleOfOptions(options: BaseAcquiringOptions) = bundleOf(EXTRA_OPTIONS to options) -fun BaseAcquiringOptions.toBundle() = bundleOf(EXTRA_OPTIONS to this) +fun BaseAcquiringOptions.toBundle() = bundleOf(EXTRA_OPTIONS to this) + +fun Intent.putOptions(options: BaseAcquiringOptions) { + putExtra(EXTRA_OPTIONS, options) +} fun Intent.getOptions(): T { return checkNotNull(getParcelableExtra(EXTRA_OPTIONS)) { diff --git a/ui/src/main/res/layout/acq_fragment_cvc_input.xml b/ui/src/main/res/layout/acq_fragment_cvc_input.xml new file mode 100644 index 00000000..5460014f --- /dev/null +++ b/ui/src/main/res/layout/acq_fragment_cvc_input.xml @@ -0,0 +1,11 @@ + + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_fragment_email_input.xml b/ui/src/main/res/layout/acq_fragment_email_input.xml new file mode 100644 index 00000000..4677fdf3 --- /dev/null +++ b/ui/src/main/res/layout/acq_fragment_email_input.xml @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_layout_choosen_card.xml b/ui/src/main/res/layout/acq_layout_choosen_card.xml new file mode 100644 index 00000000..d2bfac9a --- /dev/null +++ b/ui/src/main/res/layout/acq_layout_choosen_card.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_payment_by_card_new_activity.xml b/ui/src/main/res/layout/acq_payment_by_card_new_activity.xml index f583caf3..5f4634e7 100644 --- a/ui/src/main/res/layout/acq_payment_by_card_new_activity.xml +++ b/ui/src/main/res/layout/acq_payment_by_card_new_activity.xml @@ -19,6 +19,7 @@ android:id="@+id/acq_payment_by_card_root" android:layout_width="match_parent" android:layout_height="match_parent" + android:animateLayoutChanges="true" android:background="@color/acq_colorMain" android:orientation="vertical"> @@ -30,6 +31,12 @@ app:navigationIcon="@drawable/acq_ic_close" app:titleTextAppearance="@style/AcqToolbarTitleStyle" /> + + + + android:text="@string/acq_label_email" /> + + Добавить карту Номер + Электронная почта Срок Код + CVV Добавить Карта •%1$s добавлена @@ -53,6 +55,8 @@ Ошибка добавления карты + %1$s pay + Доступен %d символ Доступно %d символа diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 6d1a9240..ae9c1da6 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -48,8 +48,10 @@ Attach card Number + Email address Expiry date Code + CVV Attach @@ -72,6 +74,8 @@ Error attaching card + %1$s pay + %d symbol available %d symbols available From 2b72c5ba264d7c75db814e7085844e37e0ebe0f1 Mon Sep 17 00:00:00 2001 From: jqwout Date: Sun, 29 Jan 2023 13:21:27 +0300 Subject: [PATCH 029/126] payment form stub --- .../acquiring/sample/ui/PayableActivity.kt | 14 +-- ui/src/main/AndroidManifest.xml | 6 + .../tinkoff/acquiring/sdk/TinkoffAcquiring.kt | 10 +- .../sdk/payment/PaymentByCardProcess.kt | 1 - .../cards/list/adapters/CardsListAdapter.kt | 20 +++- .../cards/list/models/CardItemUiModel.kt | 4 +- .../list/presentation/CardsListViewModel.kt | 54 ++++++++- .../cards/list/ui/CardsListActivity.kt | 44 +++++-- .../redesign/cards/list/ui/CardsListState.kt | 5 +- .../common/carddatainput/CvcComponent.kt | 34 +++--- .../redesign/mainform/MainPaymentFormStub.kt | 55 +++++++++ .../payment/ui/ChosenCardComponent.kt | 14 ++- .../payment/ui/PaymentByCardActivity.kt | 110 ++++++++++-------- .../payment/ui/PaymentByCardViewModel.kt | 45 +++++-- .../tinkoff/acquiring/sdk/utils/IntentExt.kt | 11 ++ .../sdk/viewmodel/ViewModelProviderFactory.kt | 10 +- ui/src/main/res/layout/acq_card_list_item.xml | 11 ++ .../res/layout/acq_fragment_cvc_input.xml | 2 +- .../res/layout/acq_layout_choosen_card.xml | 8 +- ui/src/main/res/layout/acq_main_from_stub.xml | 12 ++ ui/src/main/res/values-ru/strings.xml | 1 + ui/src/main/res/values/strings.xml | 1 + 22 files changed, 345 insertions(+), 127 deletions(-) create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/mainform/MainPaymentFormStub.kt create mode 100644 ui/src/main/res/layout/acq_main_from_stub.xml diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt index dad3aefa..54454cd9 100644 --- a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt @@ -41,6 +41,7 @@ import ru.tinkoff.acquiring.sdk.models.AsdkState import ru.tinkoff.acquiring.sdk.models.GooglePayParams import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions import ru.tinkoff.acquiring.sdk.payment.* +import ru.tinkoff.acquiring.sdk.redesign.mainform.MainPaymentFormStub import ru.tinkoff.acquiring.sdk.redesign.payment.ui.PaymentByCard import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.SbpNoBanksStubActivity import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.SbpResult @@ -89,17 +90,6 @@ open class PayableActivity : AppCompatActivity() { is SbpResult.Canceled -> toast("SBP canceled") } } - private val byCardPayment = registerForActivityResult(PaymentByCard.Contract) { result -> - when (result) { - is PaymentByCard.Success -> { - toast("Payment Success") - } - is PaymentByCard.Error -> toast( - result.error.message ?: getString(R.string.error_title) - ) - is PaymentByCard.Canceled -> toast("Payment canceled") - } - } override fun onCreate(savedInstanceState: Bundle?) { @@ -177,7 +167,7 @@ open class PayableActivity : AppCompatActivity() { publicKey = TerminalsManager.selectedTerminal.publicKey ) } - byCardPayment.launch(PaymentByCard.StartData(options, ArrayList())) + startActivity(MainPaymentFormStub.intent(options, this)) } protected fun openDynamicQrScreen() { diff --git a/ui/src/main/AndroidManifest.xml b/ui/src/main/AndroidManifest.xml index 33af265b..c41cb41f 100644 --- a/ui/src/main/AndroidManifest.xml +++ b/ui/src/main/AndroidManifest.xml @@ -109,6 +109,12 @@ android:screenOrientation="unspecified" android:theme="@style/AcquiringTheme.Base" /> + + diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt index 12350dc3..7ee91ff9 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt @@ -548,10 +548,16 @@ class TinkoffAcquiring( object ChoseCard { sealed class Result - class Success(val card: CardData) : Result() + class Success(val card: Card) : Result() class Canceled : Result() class Error(val error: Throwable) : Result() + fun createSuccessIntent(card: Card): Intent { + val intent = Intent() + intent.putExtra(EXTRA_CHOSEN_CARD, card) + return intent + } + object Contract : ActivityResultContract() { override fun createIntent(context: Context, input: SavedCardsOptions): Intent = @@ -561,7 +567,7 @@ class TinkoffAcquiring( override fun parseResult(resultCode: Int, intent: Intent?): Result = when (resultCode) { AppCompatActivity.RESULT_OK -> Success( - intent?.getSerializableExtra(EXTRA_CHOSEN_CARD) as CardData + intent?.getSerializableExtra(EXTRA_CHOSEN_CARD) as Card ) RESULT_ERROR -> Error(intent!!.getSerializableExtra(EXTRA_ERROR)!! as Throwable) else -> Canceled() diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentByCardProcess.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentByCardProcess.kt index 8f235b1c..7913bb48 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentByCardProcess.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentByCardProcess.kt @@ -180,7 +180,6 @@ class PaymentByCardProcess internal constructor( } } - sealed interface PaymentByCardState { object Created : PaymentByCardState diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt index 42922181..d97fa2eb 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/adapters/CardsListAdapter.kt @@ -16,7 +16,8 @@ import ru.tinkoff.acquiring.sdk.viewmodel.CardLogoProvider * Created by Ivan Golovachev */ class CardsListAdapter( - private val onDeleteClick: (CardItemUiModel) -> Unit + private val onDeleteClick: (CardItemUiModel) -> Unit, + private val onChooseClick: (CardItemUiModel) -> Unit ) : RecyclerView.Adapter() { private val cards = mutableListOf() @@ -45,7 +46,7 @@ class CardsListAdapter( } override fun onBindViewHolder(holder: CardViewHolder, position: Int) { - holder.bind(cards[position], onDeleteClick) + holder.bind(cards[position], onDeleteClick, onChooseClick) } override fun onBindViewHolder( @@ -72,10 +73,13 @@ class CardsListAdapter( itemView.findViewById(R.id.acq_card_list_item_masked_name) private val cardDeleteIcon = itemView.findViewById(R.id.acq_card_list_item_delete) + private val cardChooseIcon = + itemView.findViewById(R.id.acq_card_list_item_choose) fun bind( card: CardItemUiModel, - onDeleteClick: (CardItemUiModel) -> Unit + onDeleteClick: (CardItemUiModel) -> Unit, + onChooseClick: (CardItemUiModel) -> Unit ) { cardLogo.setImageResource(CardLogoProvider.getCardLogo(card.pan)) cardNameView.text = itemView.context.getString( @@ -84,11 +88,21 @@ class CardsListAdapter( card.tail ) bindDeleteVisibility(card.showDelete) + bindChooseVisibility(card.showChoose) cardDeleteIcon.setOnClickListener { onDeleteClick(card) } + itemView.setOnClickListener { + onChooseClick(card) + } } fun bindDeleteVisibility(showDelete: Boolean) { cardDeleteIcon.isVisible = showDelete + cardChooseIcon.isVisible = false + } + + fun bindChooseVisibility(showChosen: Boolean) { + cardDeleteIcon.isVisible = false + cardChooseIcon.isVisible = showChosen } } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/models/CardItemUiModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/models/CardItemUiModel.kt index aae75b91..dcbf79cf 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/models/CardItemUiModel.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/models/CardItemUiModel.kt @@ -3,10 +3,12 @@ package ru.tinkoff.acquiring.sdk.redesign.cards.list.models import ru.tinkoff.acquiring.sdk.models.Card data class CardItemUiModel( - private val card: Card, + val card: Card, val showDelete: Boolean = false, + val showChoose: Boolean = false, + val isBlocked: Boolean = false, val bankName: String? diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt index 843fc134..aff187ff 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt @@ -1,12 +1,17 @@ package ru.tinkoff.acquiring.sdk.redesign.cards.list.presentation import androidx.annotation.VisibleForTesting +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory import kotlinx.coroutines.Job import kotlinx.coroutines.flow.* import ru.tinkoff.acquiring.sdk.AcquiringSdk import ru.tinkoff.acquiring.sdk.models.Card import ru.tinkoff.acquiring.sdk.models.enums.CardStatus +import ru.tinkoff.acquiring.sdk.models.options.screen.SavedCardsOptions import ru.tinkoff.acquiring.sdk.redesign.cards.list.models.CardItemUiModel import ru.tinkoff.acquiring.sdk.redesign.cards.list.ui.CardListEvent import ru.tinkoff.acquiring.sdk.redesign.cards.list.ui.CardListMode @@ -15,17 +20,22 @@ import ru.tinkoff.acquiring.sdk.responses.GetCardListResponse import ru.tinkoff.acquiring.sdk.utils.BankCaptionProvider import ru.tinkoff.acquiring.sdk.utils.ConnectionChecker import ru.tinkoff.acquiring.sdk.utils.CoroutineManager +import ru.tinkoff.acquiring.sdk.utils.getExtra /** * Created by Ivan Golovachev */ internal class CardsListViewModel( + private val savedStateHandle: SavedStateHandle, private val sdk: AcquiringSdk, private val connectionChecker: ConnectionChecker, private val bankCaptionProvider: BankCaptionProvider, private val manager: CoroutineManager = CoroutineManager() ) : ViewModel() { + private val selectedCardId = + savedStateHandle.getExtra().features.selectedCardId + private var deleteJob: Job? = null @VisibleForTesting @@ -109,19 +119,32 @@ internal class CardsListViewModel( } } + fun chooseCard(model: CardItemUiModel) { + if(stateFlow.value.mode === CardListMode.CHOOSE) { + eventFlow.value = CardListEvent.CloseScreen(model.card) + } + } + fun onBackPressed() { - if(eventFlow.value !is CardListEvent.RemoveCardProgress) { - eventFlow.value = CardListEvent.CloseScreen + if (eventFlow.value !is CardListEvent.RemoveCardProgress) { + val state = stateFlow.value as CardsListState.Content + val card = state.cards.firstOrNull { it.id == selectedCardId } + eventFlow.value = CardListEvent.CloseScreen(card?.card) } } private fun handleGetCardListResponse(it: GetCardListResponse, recurrentOnly: Boolean) { try { val uiCards = filterCards(it.cards, recurrentOnly) + val mode = if (selectedCardId != null) { + CardListMode.CHOOSE + } else { + CardListMode.ADD + } stateFlow.value = if (uiCards.isEmpty()) { CardsListState.Empty } else { - CardsListState.Content(CardListMode.ADD, false, uiCards) + CardsListState.Content(mode, false, uiCards) } } catch (e: Exception) { handleGetCardListError(e) @@ -139,7 +162,11 @@ internal class CardsListViewModel( return activeCards.map { val cardNumber = checkNotNull(it.pan) - CardItemUiModel(card = it, bankName = bankCaptionProvider(cardNumber)) + CardItemUiModel( + card = it, + bankName = bankCaptionProvider(cardNumber), + showChoose = selectedCardId == it.cardId + ) } } @@ -165,4 +192,23 @@ internal class CardsListViewModel( manager.cancelAll() super.onCleared() } + + companion object { + fun factory( + sdk: AcquiringSdk, + connectionChecker: ConnectionChecker, + bankCaptionProvider: BankCaptionProvider, + manager: CoroutineManager = CoroutineManager() + ) = viewModelFactory { + initializer { + CardsListViewModel( + createSavedStateHandle(), + sdk, + connectionChecker, + bankCaptionProvider, + manager + ) + } + } + } } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt index 8f9d5970..baf0a76b 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt @@ -7,6 +7,7 @@ import android.view.* import android.widget.ImageView import android.widget.TextView import android.widget.ViewFlipper +import androidx.activity.viewModels import androidx.core.view.children import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope @@ -18,6 +19,7 @@ import kotlinx.coroutines.launch import ru.tinkoff.acquiring.sdk.R import ru.tinkoff.acquiring.sdk.TinkoffAcquiring import ru.tinkoff.acquiring.sdk.TinkoffAcquiring.AttachCard +import ru.tinkoff.acquiring.sdk.models.Card import ru.tinkoff.acquiring.sdk.models.options.screen.AttachCardOptions import ru.tinkoff.acquiring.sdk.models.options.screen.SavedCardsOptions import ru.tinkoff.acquiring.sdk.redesign.cards.list.adapters.CardsListAdapter @@ -31,7 +33,13 @@ import ru.tinkoff.acquiring.sdk.utils.lazyView internal class CardsListActivity : TransparentActivity() { - private lateinit var viewModel: CardsListViewModel + private val viewModel: CardsListViewModel by viewModels { + CardsListViewModel.factory( + intent.getSdk(application).sdk, + ConnectionChecker(application), + BankCaptionResourceProvider(application) + ) + } private lateinit var savedCardsOptions: SavedCardsOptions private var mode = CardListMode.STUB @@ -82,8 +90,6 @@ internal class CardsListActivity : TransparentActivity() { super.onCreate(savedInstanceState) savedCardsOptions = options as SavedCardsOptions setContentView(R.layout.acq_activity_card_list) - - viewModel = provideViewModel(CardsListViewModel::class.java) as CardsListViewModel viewModel.loadData( savedCardsOptions.customer.customerKey, options.features.showOnlyRecurrentCards @@ -95,6 +101,7 @@ internal class CardsListActivity : TransparentActivity() { // todo // options.features.selectedCardId + selectedCardId = intent.getOptions().features.selectedCardId } override fun onCreateOptionsMenu(menu: Menu): Boolean { @@ -118,6 +125,11 @@ internal class CardsListActivity : TransparentActivity() { } } + override fun onSupportNavigateUp(): Boolean { + viewModel.onBackPressed() + return true + } + override fun onBackPressed() { viewModel.onBackPressed() } @@ -130,9 +142,10 @@ internal class CardsListActivity : TransparentActivity() { } private fun initViews() { - cardsListAdapter = CardsListAdapter(onDeleteClick = { - viewModel.deleteCard(it, savedCardsOptions.customer.customerKey!!) - }) + cardsListAdapter = CardsListAdapter( + onDeleteClick = { viewModel.deleteCard(it, savedCardsOptions.customer.customerKey!!) }, + onChooseClick = { viewModel.chooseCard(it) } + ) recyclerView.adapter = cardsListAdapter addNewCard.setOnClickListener { startAttachCard() } } @@ -247,7 +260,11 @@ internal class CardsListActivity : TransparentActivity() { ) } is CardListEvent.CloseScreen -> { - finish() + if (it.selectedCard != null) { + finishWithCard(it.selectedCard) + } else { + finish() + } } } } @@ -279,9 +296,15 @@ internal class CardsListActivity : TransparentActivity() { } override fun finish() { + + if (selectedCardId == null) { + setResult(Activity.RESULT_CANCELED, intent) + super.finish() + } + if (!isErrorOccurred) { val intent = Intent() - intent.putExtra(TinkoffAcquiring.EXTRA_CARD_ID, selectedCardId) + intent.putExtra(TinkoffAcquiring.EXTRA_CHOSEN_CARD, selectedCardId) intent.putExtra(TinkoffAcquiring.EXTRA_CARD_LIST_CHANGED, isCardListChanged) setResult(Activity.RESULT_OK, intent) } @@ -301,4 +324,9 @@ internal class CardsListActivity : TransparentActivity() { snackBarHelper.hide() } } + + private fun finishWithCard(card: Card) { + setResult(Activity.RESULT_OK, TinkoffAcquiring.ChoseCard.createSuccessIntent(card)) + super.finish() + } } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt index d92b6f5e..6154871b 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt @@ -1,5 +1,6 @@ package ru.tinkoff.acquiring.sdk.redesign.cards.list.ui +import ru.tinkoff.acquiring.sdk.models.Card import ru.tinkoff.acquiring.sdk.redesign.cards.list.models.CardItemUiModel /** @@ -27,9 +28,9 @@ sealed class CardListEvent { object ShowError : CardListEvent() - object CloseScreen : CardListEvent() + class CloseScreen(val selectedCard: Card?) : CardListEvent() } enum class CardListMode { - ADD, DELETE, STUB + ADD, DELETE, STUB, CHOOSE } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CvcComponent.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CvcComponent.kt index 8abd4004..0f6a5905 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CvcComponent.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CvcComponent.kt @@ -1,6 +1,7 @@ package ru.tinkoff.acquiring.sdk.redesign.common.carddatainput import android.text.method.PasswordTransformationMethod +import android.view.View import android.view.ViewGroup import ru.tinkoff.acquiring.sdk.R import ru.tinkoff.acquiring.sdk.smartfield.AcqTextFieldView @@ -18,10 +19,10 @@ import ru.tinkoff.decoro.watchers.MaskFormatWatcher class CvcComponent( val root: ViewGroup, val onInputComplete: (String) -> Unit = {}, - val onDataChange: (Boolean) -> Unit = {} + val onDataChange: (Boolean, String) -> Unit = { _, _ ->} ) : UiComponent { - private val cvcInput: AcqTextFieldView = root.findViewById(R.id.card_number_input) + private val cvcInput: AcqTextFieldView = root.findViewById(R.id.cvc_input) val cvc get() = cvcInput.text.orEmpty() init { @@ -33,29 +34,30 @@ class CvcComponent( errorHighlighted = false val cvc = cvc - if (cvc.length >= CVC_MASK.length) { - if (CardValidator.validateSecurityCode(cvc)) { + if (cvc.length > CVC_MASK.length) { + if (validate(cvc)) { cvcInput.clearViewFocus() - if (validate()) { - onInputComplete(cvc) - } + onInputComplete(cvc) } else { errorHighlighted = true } } - onDataChange(validate()) + onDataChange(validate(cvc), cvc) + } + + editText.onFocusChangeListener = View.OnFocusChangeListener { view, hasFocus -> + if (hasFocus.not()) { + val isValid = validate(cvc) + errorHighlighted = isValid.not() + onDataChange(isValid, cvc) + } } } } - fun validate(): Boolean { - var result = true - if (CardValidator.validateSecurityCode(cvc)) { - cvcInput.errorHighlighted = true - result = false - } - return result + private fun validate(code: String): Boolean { + return CardValidator.validateSecurityCode(code) } override fun render(state: String?) { @@ -67,6 +69,8 @@ class CvcComponent( } companion object { + + const val EXPIRY_DATE_MASK = "__/__" const val CVC_MASK = "___" fun createCvcMask(): MaskImpl = MaskImpl .createTerminated(UnderscoreDigitSlotsParser().parseSlots(CVC_MASK)) diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/mainform/MainPaymentFormStub.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/mainform/MainPaymentFormStub.kt new file mode 100644 index 00000000..65302290 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/mainform/MainPaymentFormStub.kt @@ -0,0 +1,55 @@ +package ru.tinkoff.acquiring.sdk.redesign.mainform + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import ru.tinkoff.acquiring.sdk.TinkoffAcquiring +import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions +import ru.tinkoff.acquiring.sdk.redesign.payment.ui.PaymentByCard +import ru.tinkoff.acquiring.sdk.utils.getOptions +import ru.tinkoff.acquiring.sdk.utils.lazyUnsafe +import ru.tinkoff.acquiring.sdk.utils.putOptions + +/** + * Created by i.golovachev + */ +// rework after desing main form +class MainPaymentFormStub : AppCompatActivity() { + + val options by lazyUnsafe { intent.getOptions() } + private val byCardPayment = registerForActivityResult(PaymentByCard.Contract) { result -> + finish() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + lifecycleScope.launch(Dispatchers.IO) { + val cardList = TinkoffAcquiring( + applicationContext, + options.terminalKey, + options.publicKey + ).sdk.getCardList { this.customerKey = options.customer.customerKey }.execute() + + + byCardPayment.launch( + PaymentByCard.StartData( + options, + ArrayList(cardList.cards.toMutableList()) + ) + ) + } + } + + companion object { + fun intent(options: PaymentOptions, context: Context): Intent { + val intent = Intent(context, MainPaymentFormStub::class.java) + intent.putOptions(options) + return intent + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/ChosenCardComponent.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/ChosenCardComponent.kt index 09d04f49..738d4713 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/ChosenCardComponent.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/ChosenCardComponent.kt @@ -15,22 +15,26 @@ import ru.tinkoff.acquiring.sdk.viewmodel.CardLogoProvider internal class ChosenCardComponent( private val root: ViewGroup, private val onChangeCard: (CardChosenModel) -> Unit = {}, - private val onCvcCompleted: (String) -> Unit = {} + private val onCvcCompleted: (String, Boolean) -> Unit = { _, _ -> } ) : UiComponent { private val cardLogo: ImageView = root.findViewById(R.id.acq_card_choosen_item_logo) private val cardName: TextView = root.findViewById(R.id.acq_card_choosen_item) private val cardChange: TextView = root.findViewById(R.id.acq_card_change) private val cardCvc: CvcComponent = CvcComponent( - root.findViewById(R.id.cvc_container), onInputComplete = onCvcCompleted + root.findViewById(R.id.cvc_container), onDataChange = { b, s -> + onCvcCompleted(s, b) + } ) override fun render(state: CardChosenModel) = with(state) { cardLogo.setImageResource(CardLogoProvider.getCardLogo(pan)) cardName.text = root.context.getString( - R.string.acq_cardlist_bankname, bankName, tail + R.string.acq_cardlist_bankname, bankName.orEmpty(), tail ) - cardChange.setOnClickListener { onChangeCard(state) } + root.setOnClickListener { onChangeCard(state) } + cardCvc.root.setOnClickListener { + cardCvc.requestViewFocus() + } } - } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardActivity.kt index b31a849e..7020e8ee 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardActivity.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardActivity.kt @@ -11,6 +11,8 @@ import androidx.core.view.isVisible import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.commit import androidx.lifecycle.* +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch import ru.tinkoff.acquiring.sdk.R import ru.tinkoff.acquiring.sdk.TinkoffAcquiring import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions @@ -25,6 +27,7 @@ import ru.tinkoff.acquiring.sdk.redesign.payment.ui.PaymentByCard.Contract.EXTRA import ru.tinkoff.acquiring.sdk.redesign.payment.ui.PaymentByCard.Contract.createSuccessIntent import ru.tinkoff.acquiring.sdk.threeds.ThreeDsHelper import ru.tinkoff.acquiring.sdk.ui.activities.TransparentActivity +import ru.tinkoff.acquiring.sdk.ui.component.bindKtx import ru.tinkoff.acquiring.sdk.ui.customview.LoaderButton import ru.tinkoff.acquiring.sdk.utils.* import ru.tinkoff.acquiring.sdk.utils.lazyUnsafe @@ -41,53 +44,46 @@ internal class PaymentByCardActivity : AppCompatActivity(), private val startData: PaymentByCard.StartData by lazyUnsafe { intent.getParcelableExtra(EXTRA_SAVED_CARDS)!! } - private val savedCardOptions: SavedCardsOptions by lazyUnsafe { SavedCardsOptions().apply { setTerminalParams( startData.paymentOptions.terminalKey, startData.paymentOptions.publicKey ) + customer = startData.paymentOptions.customer + features = startData.paymentOptions.features } } - private val cardDataInputContainer: FragmentContainerView by lazyView(R.id.fragment_card_data_input) - private val cardDataInput get() = supportFragmentManager.findFragmentById(R.id.fragment_card_data_input) as CardDataInputFragment - private val chosenCardContainer: CardView by lazyView(R.id.acq_chosen_card) - private val chosenCardComponent: ChosenCardComponent by lazyUnsafe { ChosenCardComponent( chosenCardContainer, onChangeCard = { onChangeCard() }, - onCvcCompleted = viewModel::setCvc + onCvcCompleted = { cvc, isValid -> viewModel.setCvc(cvc, isValid) } ) } - private val emailInput: EmailInputFragment by lazyUnsafe { EmailInputFragment.getInstance(startData.paymentOptions.customer.email) } - private val emailInputContainer: FragmentContainerView by lazyView(R.id.fragment_email_input) - private val sendReceiptSwitch: SwitchCompat by lazyView(R.id.acq_send_receipt_switch) - private val payButton: LoaderButton by lazyView(R.id.acq_pay_btn) - - private val viewModel: PaymentByCardViewModel by viewModels { PaymentByCardViewModel.factory() } - + private val viewModel: PaymentByCardViewModel by viewModels { + PaymentByCardViewModel.factory(application) + } private val statusSheetStatus = createPaymentSheetWrapper() - private val savedCards = registerForActivityResult(TinkoffAcquiring.ChoseCard.Contract) { result -> when (result) { - is TinkoffAcquiring.ChoseCard.Success -> result.card + is TinkoffAcquiring.ChoseCard.Success -> viewModel.setSavedCard(result.card) else -> Unit } } + //region Activity LC override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.acq_payment_by_card_new_activity) @@ -96,6 +92,9 @@ internal class PaymentByCardActivity : AppCompatActivity(), lifecycleScope.launchWhenCreated { processState() } lifecycleScope.launchWhenCreated { uiState() } + lifecycleScope.launch { selectedCardState() } + + chosenCardComponent.bindKtx(lifecycleScope, viewModel.state.mapNotNull { it.chosenCard }) } override fun onResume() { @@ -110,6 +109,31 @@ internal class PaymentByCardActivity : AppCompatActivity(), } } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == TransparentActivity.THREE_DS_REQUEST_CODE) { + if (resultCode == Activity.RESULT_OK && data != null) { + statusSheetStatus.state = PaymentStatusSheetState.Success( + title = R.string.acq_commonsheet_paid_title, + mainButton = R.string.acq_commonsheet_clear_primarybutton, + resultData = data.getSerializableExtra(ThreeDsHelper.Launch.RESULT_DATA) as AsdkResult + ) + } else if (resultCode == ThreeDsHelper.Launch.RESULT_ERROR) { + statusSheetStatus.state = PaymentStatusSheetState.Error( + title = R.string.acq_commonsheet_failed_title, + mainButton = R.string.acq_commonsheet_failed_primary_button, + throwable = data?.getSerializableExtra(ThreeDsHelper.Launch.ERROR_DATA) as Throwable + ) + } else { + setResult(Activity.RESULT_CANCELED) + finish() + } + } else { + super.onActivityResult(requestCode, resultCode, data) + } + } + //endregion + + //region Data change callbacks override fun onCardDataChanged(isValid: Boolean) { viewModel.setCardDate( cardNumber = cardDataInput.cardNumber, @@ -122,18 +146,9 @@ internal class PaymentByCardActivity : AppCompatActivity(), override fun onEmailDataChanged(isValid: Boolean) { viewModel.setEmail(email = emailInput.emailValue, isValidEmail = isValid) } + //endregion - override fun onClose(state: PaymentStatusSheetState) { - when (state) { - is PaymentStatusSheetState.Error -> statusSheetStatus.dismissAllowingStateLoss() - is PaymentStatusSheetState.Success -> finishWithSuccess(state.resultData as PaymentResult) - else -> { - setResult(RESULT_CANCELED) - finish() - } - } - } - + //region Navigation private fun onChangeCard() { savedCards.launch(savedCardOptions) } @@ -149,6 +164,19 @@ internal class PaymentByCardActivity : AppCompatActivity(), finish() } + override fun onClose(state: PaymentStatusSheetState) { + when (state) { + is PaymentStatusSheetState.Error -> statusSheetStatus.dismissAllowingStateLoss() + is PaymentStatusSheetState.Success -> finishWithSuccess(state.resultData as PaymentResult) + else -> { + setResult(RESULT_CANCELED) + finish() + } + } + } + //endregion + + //region init views private fun initToolbar() { setSupportActionBar(findViewById(R.id.acq_toolbar)) supportActionBar?.setDisplayHomeAsUpEnabled(true) @@ -170,11 +198,13 @@ internal class PaymentByCardActivity : AppCompatActivity(), viewModel.pay() } } + //endregion + //region subscribe States private suspend fun uiState() { viewModel.state.collect { - chosenCardContainer.isVisible = it.hasSavedCard - cardDataInputContainer.isVisible = it.hasSavedCard.not() + chosenCardContainer.isVisible = it.chosenCard != null + cardDataInputContainer.isVisible = it.chosenCard == null emailInputContainer.isVisible = it.sendReceipt sendReceiptSwitch.isChecked = it.sendReceipt payButton.text = getString(R.string.acq_cardpay_pay, it.amount) @@ -224,28 +254,12 @@ internal class PaymentByCardActivity : AppCompatActivity(), } } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (requestCode == TransparentActivity.THREE_DS_REQUEST_CODE) { - if (resultCode == Activity.RESULT_OK && data != null) { - statusSheetStatus.state = PaymentStatusSheetState.Success( - title = R.string.acq_commonsheet_paid_title, - mainButton = R.string.acq_commonsheet_clear_primarybutton, - resultData = data.getSerializableExtra(ThreeDsHelper.Launch.RESULT_DATA) as AsdkResult - ) - } else if (resultCode == ThreeDsHelper.Launch.RESULT_ERROR) { - statusSheetStatus.state = PaymentStatusSheetState.Error( - title = R.string.acq_commonsheet_failed_title, - mainButton = R.string.acq_commonsheet_failed_primary_button, - throwable = data?.getSerializableExtra(ThreeDsHelper.Launch.ERROR_DATA) as Throwable - ) - } else { - setResult(Activity.RESULT_CANCELED) - finish() - } - } else { - super.onActivityResult(requestCode, resultCode, data) + private suspend fun selectedCardState() { + viewModel.state.collectLatest { + savedCardOptions.featuresOptions { selectedCardId = it.chosenCard?.id } } } + //endregion private fun finishWithSuccess(result: PaymentResult) { setResult(RESULT_OK, createSuccessIntent(result)) diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardViewModel.kt index 7717cc1b..82a54597 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardViewModel.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardViewModel.kt @@ -1,23 +1,32 @@ package ru.tinkoff.acquiring.sdk.redesign.payment.ui +import android.app.Application import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.createSavedStateHandle import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.* +import ru.tinkoff.acquiring.sdk.models.Card +import ru.tinkoff.acquiring.sdk.models.enums.CardStatus import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions import ru.tinkoff.acquiring.sdk.models.paysources.CardData import ru.tinkoff.acquiring.sdk.payment.PaymentByCardProcess +import ru.tinkoff.acquiring.sdk.redesign.payment.model.CardChosenModel +import ru.tinkoff.acquiring.sdk.utils.BankCaptionProvider +import ru.tinkoff.acquiring.sdk.utils.BankCaptionResourceProvider internal class PaymentByCardViewModel( private val savedStateHandle: SavedStateHandle, - private val paymentByCardProcess: PaymentByCardProcess + private val paymentByCardProcess: PaymentByCardProcess, + private val bankCaptionProvider: BankCaptionProvider, ) : ViewModel() { private val startData = savedStateHandle.get(PaymentByCard.Contract.EXTRA_SAVED_CARDS)!! + private val chosenCard = startData.list.firstOrNull { it.status == CardStatus.ACTIVE }?.let { + CardChosenModel(it, bankCaptionProvider(it.pan!!)) + } val paymentProcessState = paymentByCardProcess.state @@ -28,7 +37,7 @@ internal class PaymentByCardViewModel( sendReceipt = startData.paymentOptions.customer.email.isNullOrBlank().not(), email = startData.paymentOptions.customer.email, paymentOptions = startData.paymentOptions, - hasSavedCard = startData.list.isNotEmpty() + chosenCard = chosenCard ) ) @@ -36,17 +45,28 @@ internal class PaymentByCardViewModel( cardNumber: String? = null, cvc: String? = null, dateExpired: String? = null, - isValidCardData: Boolean = false + isValidCardData: Boolean = false, ) = state.update { it.copy( cardNumber = cardNumber, cvc = cvc, dateExpired = dateExpired, - isValidCardData = isValidCardData + isValidCardData = isValidCardData, ) } - fun setCvc(cvc: String) = state.update { it.copy(cvc = cvc) } + fun setSavedCard(card: Card) = state.update { + it.copy( + cardNumber = card.pan, + cvc = null, + dateExpired = card.expDate, + isValidCardData = false, + chosenCard = CardChosenModel(card, bankCaptionProvider(card.pan!!)) + ) + } + + fun setCvc(cvc: String, isValid: Boolean) = + state.update { it.copy(cvc = cvc, isValidCardData = isValid) } fun sendReceiptChange(isSelect: Boolean) = state.update { it.copy(sendReceipt = isSelect) @@ -72,11 +92,9 @@ internal class PaymentByCardViewModel( private val dateExpired: String? = null, private val isValidCardData: Boolean = false, private val isValidEmail: Boolean = false, - - val hasSavedCard: Boolean = false, + val chosenCard: CardChosenModel? = null, val sendReceipt: Boolean = false, val email: String? = null, - val paymentOptions: PaymentOptions, ) { @@ -101,9 +119,12 @@ internal class PaymentByCardViewModel( } companion object { - fun factory() = viewModelFactory { + fun factory(application: Application) = viewModelFactory { initializer { - PaymentByCardViewModel(createSavedStateHandle(), PaymentByCardProcess.get()) + PaymentByCardViewModel( + createSavedStateHandle(), PaymentByCardProcess.get(), + BankCaptionResourceProvider(application) + ) } } } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/IntentExt.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/IntentExt.kt index cbe7fd6f..28ecd6a4 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/IntentExt.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/IntentExt.kt @@ -1,8 +1,10 @@ package ru.tinkoff.acquiring.sdk.utils +import android.app.Application import android.content.Intent import androidx.core.os.bundleOf import androidx.lifecycle.SavedStateHandle +import ru.tinkoff.acquiring.sdk.TinkoffAcquiring import ru.tinkoff.acquiring.sdk.models.options.screen.BaseAcquiringOptions /** @@ -20,6 +22,15 @@ fun Intent.putOptions(options: BaseAcquiringOptions) { putExtra(EXTRA_OPTIONS, options) } +internal fun Intent.getSdk(application: Application): TinkoffAcquiring { + val opt = getOptions() + return TinkoffAcquiring( + application, + opt.terminalKey, + opt.publicKey + ) +} + fun Intent.getOptions(): T { return checkNotNull(getParcelableExtra(EXTRA_OPTIONS)) { "extra by key $EXTRA_OPTIONS not fount" diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/ViewModelProviderFactory.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/ViewModelProviderFactory.kt index ff6f6253..6f28c36a 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/ViewModelProviderFactory.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/ViewModelProviderFactory.kt @@ -49,16 +49,8 @@ internal class ViewModelProviderFactory( YandexPaymentViewModel::class.java to YandexPaymentViewModel(application, handleErrorsInSdk, sdk) ) - private val redesignViewModels = mapOf, ViewModel>( - CardsListViewModel::class.java to CardsListViewModel( - sdk, - ConnectionChecker(application), - BankCaptionResourceProvider(application) - ) - ) - @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { - return (redesignViewModels + viewModelCollection)[modelClass] as T + return (viewModelCollection)[modelClass] as T } } \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_card_list_item.xml b/ui/src/main/res/layout/acq_card_list_item.xml index 683cb576..c9711f79 100644 --- a/ui/src/main/res/layout/acq_card_list_item.xml +++ b/ui/src/main/res/layout/acq_card_list_item.xml @@ -54,6 +54,17 @@ android:padding="8dp" android:src="@drawable/acq_ic_cardlist_delete" /> + + + \ No newline at end of file diff --git a/ui/src/main/res/layout/acq_fragment_cvc_input.xml b/ui/src/main/res/layout/acq_fragment_cvc_input.xml index 5460014f..bebc8e0b 100644 --- a/ui/src/main/res/layout/acq_fragment_cvc_input.xml +++ b/ui/src/main/res/layout/acq_fragment_cvc_input.xml @@ -4,7 +4,7 @@ android:id="@+id/cvc_input" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:inputType="textEmailAddress" + android:inputType="number" android:minWidth="64dp" android:minHeight="48dp" android:gravity="center" diff --git a/ui/src/main/res/layout/acq_layout_choosen_card.xml b/ui/src/main/res/layout/acq_layout_choosen_card.xml index d2bfac9a..b7801ed7 100644 --- a/ui/src/main/res/layout/acq_layout_choosen_card.xml +++ b/ui/src/main/res/layout/acq_layout_choosen_card.xml @@ -4,6 +4,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" + android:layout_marginHorizontal="16dp" app:cardCornerRadius="16dp" app:cardElevation="20dp"> @@ -43,22 +44,21 @@ + app:layout_constraintTop_toBottomOf="@+id/acq_card_choosen_item" /> + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/values-ru/strings.xml b/ui/src/main/res/values-ru/strings.xml index 61151f76..5de910d4 100644 --- a/ui/src/main/res/values-ru/strings.xml +++ b/ui/src/main/res/values-ru/strings.xml @@ -96,5 +96,6 @@ Попробуйте оплатить снова или выберите другой способ оплаты Оплатить еще раз Другой способ оплаты + Сменить карту \ No newline at end of file diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index ae9c1da6..af52cfb5 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -95,5 +95,6 @@ Choose bank You need a bank app wit Fast Payment System support to make the payment Details + Change card From be6aa2fff3290dba4c3514be7360dc494269a00e Mon Sep 17 00:00:00 2001 From: jqwout Date: Mon, 30 Jan 2023 13:53:08 +0300 Subject: [PATCH 030/126] payment form stub --- .../sdk/payment/PaymentByCardProcess.kt | 6 +-- .../payment/ui/PaymentByCardViewModel.kt | 43 ++++++++++++------- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentByCardProcess.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentByCardProcess.kt index 7913bb48..f0792dd8 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentByCardProcess.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentByCardProcess.kt @@ -32,12 +32,12 @@ class PaymentByCardProcess internal constructor( private val coroutineManager: CoroutineManager = CoroutineManager() ) { - private lateinit var paymentSource: CardData + private lateinit var paymentSource: CardSource private val _state = MutableStateFlow(PaymentByCardState.Created) val state = _state.asStateFlow() fun start( - cardData: CardData, + cardData: CardSource, paymentOptions: PaymentOptions, email: String? = null ) { @@ -56,7 +56,7 @@ class PaymentByCardProcess internal constructor( } private suspend fun callInitRequest( - cardData: CardData, + cardData: CardSource, paymentOptions: PaymentOptions, email: String? ) { diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardViewModel.kt index 82a54597..5423c1b7 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardViewModel.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardViewModel.kt @@ -10,7 +10,9 @@ import kotlinx.coroutines.flow.* import ru.tinkoff.acquiring.sdk.models.Card import ru.tinkoff.acquiring.sdk.models.enums.CardStatus import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions +import ru.tinkoff.acquiring.sdk.models.paysources.AttachedCard import ru.tinkoff.acquiring.sdk.models.paysources.CardData +import ru.tinkoff.acquiring.sdk.models.paysources.CardSource import ru.tinkoff.acquiring.sdk.payment.PaymentByCardProcess import ru.tinkoff.acquiring.sdk.redesign.payment.model.CardChosenModel import ru.tinkoff.acquiring.sdk.utils.BankCaptionProvider @@ -33,6 +35,7 @@ internal class PaymentByCardViewModel( val state: MutableStateFlow = MutableStateFlow( State( + cardId = chosenCard?.id, isValidEmail = startData.paymentOptions.customer.email.isNullOrBlank().not(), sendReceipt = startData.paymentOptions.customer.email.isNullOrBlank().not(), email = startData.paymentOptions.customer.email, @@ -41,22 +44,30 @@ internal class PaymentByCardViewModel( ) ) + // ручной ввод карты fun setCardDate( cardNumber: String? = null, cvc: String? = null, dateExpired: String? = null, isValidCardData: Boolean = false, - ) = state.update { - it.copy( - cardNumber = cardNumber, - cvc = cvc, - dateExpired = dateExpired, - isValidCardData = isValidCardData, - ) + ) { + if (chosenCard != null) return + + state.update { + it.copy( + cardNumber = cardNumber, + cvc = cvc, + dateExpired = dateExpired, + isValidCardData = isValidCardData, + cardId = null + ) + } } + // ввод сохраненной карты fun setSavedCard(card: Card) = state.update { it.copy( + cardId = card.cardId, cardNumber = card.pan, cvc = null, dateExpired = card.expDate, @@ -65,6 +76,7 @@ internal class PaymentByCardViewModel( ) } + // ввод кода сохраненной карты fun setCvc(cvc: String, isValid: Boolean) = state.update { it.copy(cvc = cvc, isValidCardData = isValid) } @@ -79,7 +91,8 @@ internal class PaymentByCardViewModel( fun pay() { val _state = state.value val emailForPayment = if (_state.sendReceipt) _state.email else null - paymentByCardProcess.start(_state.cardData, _state.paymentOptions, emailForPayment) + + paymentByCardProcess.start(_state.cardSource, _state.paymentOptions, emailForPayment) } fun cancelPayment() { @@ -87,6 +100,7 @@ internal class PaymentByCardViewModel( } data class State( + private val cardId: String? = null, private val cardNumber: String? = null, private val cvc: String? = null, private val dateExpired: String? = null, @@ -106,15 +120,12 @@ internal class PaymentByCardViewModel( val amount = paymentOptions.order.amount.toHumanReadableString() - val cardData: CardData + val cardSource: CardSource get() { - return CardData( - pan = cardNumber!!, - expiryDate = dateExpired!!, - securityCode = cvc!! - ).apply { - validate() - } + return if (cardId != null) + AttachedCard(cardId, cvc) + else + CardData(cardNumber!!, dateExpired!!, cvc!!) } } From 4394df25229a2e427150209eb709c96fccc482a2 Mon Sep 17 00:00:00 2001 From: jqwout Date: Tue, 24 Jan 2023 16:26:32 +0300 Subject: [PATCH 031/126] =?UTF-8?q?MC-7997=20=D0=BE=D0=BF=D0=BB=D0=B0?= =?UTF-8?q?=D1=82=D0=B0=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20=D0=A1=D0=91?= =?UTF-8?q?=D0=9F=20=D0=B8=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=D1=8B=20?= =?UTF-8?q?=D0=B2=20=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AcquiringSdkTimeoutException.kt | 24 ++ .../acquiring/sdk/models/paysources/SbpPay.kt | 11 + .../acquiring/sdk/network/AcquiringApi.kt | 2 +- .../acquiring/sample/ui/MainActivity.kt | 18 -- .../acquiring/sample/ui/PayableActivity.kt | 42 +++- .../sample/utils/CombInitDelegate.kt | 29 +++ .../acquiring/sample/utils/SessionParams.kt | 2 + .../sample/utils/SettingsSdkManager.kt | 3 + .../sample/utils/TerminalsManager.kt | 9 +- sample/src/main/res/values-ru/strings.xml | 1 + .../src/main/res/values/preferences_keys.xml | 1 + sample/src/main/res/values/strings.xml | 1 + sample/src/main/res/xml/settings.xml | 6 + ui/build.gradle | 1 + ui/src/main/AndroidManifest.xml | 2 +- .../tinkoff/acquiring/sdk/TinkoffAcquiring.kt | 65 +++++- .../acquiring/sdk/models/NspkRequest.kt | 15 +- .../acquiring/sdk/payment/PaymentState.kt | 7 +- .../sdk/payment/SbpPaymentProcess.kt | 198 ++++++++++++++++ .../sdk/payment/YandexPaymentProcess.kt | 2 +- ...tFailureInsufficientFundsDialogFragment.kt | 48 ---- .../redesign/dialog/PaymentStatusFormExt.kt | 47 ++++ .../sdk/redesign/dialog/PaymentStatusSheet.kt | 133 +++++++++++ .../sdk/redesign/sbp/ui/BankListViewModel.kt | 57 ----- ...kListActivity.kt => SbpPaymentActivity.kt} | 220 ++++++++++-------- .../redesign/sbp/ui/SbpPaymentViewModel.kt | 67 ++++++ .../redesign/sbp/util/NspkBankAppsProvider.kt | 5 + .../sbp/util/NspkInstalledAppsChecker.kt | 10 + .../sdk/redesign/sbp/util/SbpHelper.kt | 4 - .../sdk/redesign/sbp/util/SbpStateMapper.kt | 70 ++++++ .../acquiring/sdk/utils/CoroutineManager.kt | 64 +++-- .../ru/tinkoff/acquiring/sdk/utils/FlowExt.kt | 11 + .../tinkoff/acquiring/sdk/utils/NspkClient.kt | 58 ++--- .../acquiring/sdk/viewmodel/QrViewModel.kt | 6 +- .../main/res/drawable/acq_button_flat_bg.xml | 3 +- ..._funds.xml => acq_payment_status_form.xml} | 30 ++- ui/src/main/res/values-ru/strings.xml | 11 +- ui/src/main/res/values/strings.xml | 10 + ui/src/main/res/values/styles.xml | 7 + ui/src/test/java/common/AssertExt.kt | 4 +- ui/src/test/java/sbp/SbpTestEnvironment.kt | 102 ++++++++ ui/src/test/java/sbp/ShowBanksApps.kt | 28 +++ 42 files changed, 1130 insertions(+), 304 deletions(-) create mode 100644 core/src/main/java/ru/tinkoff/acquiring/sdk/exceptions/AcquiringSdkTimeoutException.kt create mode 100644 core/src/main/java/ru/tinkoff/acquiring/sdk/models/paysources/SbpPay.kt create mode 100644 sample/src/main/java/ru/tinkoff/acquiring/sample/utils/CombInitDelegate.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/SbpPaymentProcess.kt delete mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentFailureInsufficientFundsDialogFragment.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusFormExt.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusSheet.kt delete mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/BankListViewModel.kt rename ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/{BankListActivity.kt => SbpPaymentActivity.kt} (50%) create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentViewModel.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/NspkBankAppsProvider.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/NspkInstalledAppsChecker.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpStateMapper.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/FlowExt.kt rename ui/src/main/res/layout/{acq_fragment_payment_failure_insufficient_funds.xml => acq_payment_status_form.xml} (58%) create mode 100644 ui/src/test/java/sbp/SbpTestEnvironment.kt create mode 100644 ui/src/test/java/sbp/ShowBanksApps.kt diff --git a/core/src/main/java/ru/tinkoff/acquiring/sdk/exceptions/AcquiringSdkTimeoutException.kt b/core/src/main/java/ru/tinkoff/acquiring/sdk/exceptions/AcquiringSdkTimeoutException.kt new file mode 100644 index 00000000..16bfae35 --- /dev/null +++ b/core/src/main/java/ru/tinkoff/acquiring/sdk/exceptions/AcquiringSdkTimeoutException.kt @@ -0,0 +1,24 @@ +/* + * Copyright © 2020 Tinkoff Bank + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ru.tinkoff.acquiring.sdk.exceptions + +/** + * Исключение, выбрасываемое в случае, когда ожидание платежа истекло + * + * @author i.golovachev + */ +class AcquiringSdkTimeoutException(throwable: Throwable) : RuntimeException(throwable.message, throwable) \ No newline at end of file diff --git a/core/src/main/java/ru/tinkoff/acquiring/sdk/models/paysources/SbpPay.kt b/core/src/main/java/ru/tinkoff/acquiring/sdk/models/paysources/SbpPay.kt new file mode 100644 index 00000000..fbfac499 --- /dev/null +++ b/core/src/main/java/ru/tinkoff/acquiring/sdk/models/paysources/SbpPay.kt @@ -0,0 +1,11 @@ +package ru.tinkoff.acquiring.sdk.models.paysources + +import ru.tinkoff.acquiring.sdk.models.PaymentSource + +/** + * Тип оплаты с помощью SBP Pay + * + * + * Created by i.golovachev + */ +object SbpPay : PaymentSource \ No newline at end of file diff --git a/core/src/main/java/ru/tinkoff/acquiring/sdk/network/AcquiringApi.kt b/core/src/main/java/ru/tinkoff/acquiring/sdk/network/AcquiringApi.kt index c060a0ad..2778147a 100644 --- a/core/src/main/java/ru/tinkoff/acquiring/sdk/network/AcquiringApi.kt +++ b/core/src/main/java/ru/tinkoff/acquiring/sdk/network/AcquiringApi.kt @@ -75,7 +75,7 @@ object AcquiringApi { internal const val API_REQUEST_METHOD_POST = "POST" internal const val API_REQUEST_METHOD_GET = "GET" - internal const val JSON = "application/json" + const val JSON = "application/json" internal const val FORM_URL_ENCODED = "application/x-www-form-urlencoded" internal const val TIMEOUT = 40000L diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt index 9b951dc5..a0bee731 100644 --- a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt @@ -44,9 +44,6 @@ import ru.tinkoff.acquiring.sdk.localization.AsdkSource import ru.tinkoff.acquiring.sdk.localization.Language import ru.tinkoff.acquiring.sdk.models.options.FeaturesOptions import ru.tinkoff.acquiring.sdk.models.result.CardResult -import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.BankList -import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.SbpNoBanksStubActivity -import ru.tinkoff.acquiring.sdk.redesign.sbp.util.SbpHelper import ru.tinkoff.acquiring.sdk.threeds.ThreeDsHelper /** @@ -76,17 +73,6 @@ class MainActivity : AppCompatActivity(), BooksListAdapter.BookDetailsClickListe } } - private val chooseBank = registerForActivityResult(BankList.Contract) { result -> - when (result) { - is BankList.Success -> { - SbpHelper.openSbpDeeplink(result.deeplink, result.packageName, this) - } - is BankList.Error -> toast(result.error.message ?: getString(R.string.error_title)) - is BankList.NoBanks -> SbpNoBanksStubActivity.show(this) - is BankList.Canceled -> toast("SBP canceled") - } - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -149,10 +135,6 @@ class MainActivity : AppCompatActivity(), BooksListAdapter.BookDetailsClickListe startActivity(Intent(this, TerminalsActivity::class.java)) true } - R.id.sbp_bank_list -> { - chooseBank.launch("https://qr.nspk.ru/test_link") - true - } R.id.menu_action_about -> { AboutActivity.start(this) true diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt index bb9094d5..e6f9e8f1 100644 --- a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt @@ -27,8 +27,13 @@ import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isVisible import androidx.fragment.app.commit +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import ru.tinkoff.acquiring.sample.R import ru.tinkoff.acquiring.sample.SampleApplication +import ru.tinkoff.acquiring.sample.ui.MainActivity.Companion.toast +import ru.tinkoff.acquiring.sample.utils.CombInitDelegate import ru.tinkoff.acquiring.sample.utils.SessionParams import ru.tinkoff.acquiring.sample.utils.SettingsSdkManager import ru.tinkoff.acquiring.sample.utils.TerminalsManager @@ -42,6 +47,7 @@ import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions import ru.tinkoff.acquiring.sdk.payment.PaymentListener import ru.tinkoff.acquiring.sdk.payment.PaymentListenerAdapter import ru.tinkoff.acquiring.sdk.payment.PaymentState +import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.SbpNoBanksStubActivity import ru.tinkoff.acquiring.sdk.utils.GooglePayHelper import ru.tinkoff.acquiring.sdk.utils.Money import ru.tinkoff.acquiring.yandexpay.YandexButtonFragment @@ -73,6 +79,20 @@ open class PayableActivity : AppCompatActivity() { private val orderId: String get() = abs(Random().nextInt()).toString() private var acqFragment: YandexButtonFragment? = null + private val combInitDelegate: CombInitDelegate = CombInitDelegate(tinkoffAcquiring.sdk, Dispatchers.IO) + + + private val spbPayment = registerForActivityResult(tinkoffAcquiring.payWithSbpContract()) { result -> + when (result) { + is TinkoffAcquiring.SbpScreen.Success -> { + toast("SBP Success") + } + is TinkoffAcquiring.SbpScreen.Error -> toast(result.error.message ?: getString(R.string.error_title)) + is TinkoffAcquiring.SbpScreen.NoBanks -> SbpNoBanksStubActivity.show(this) + is TinkoffAcquiring.SbpScreen.Canceled -> toast("SBP canceled") + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -145,7 +165,27 @@ open class PayableActivity : AppCompatActivity() { } protected fun startSbpPayment() { - tinkoffAcquiring.payWithSbp(this, createPaymentOptions(), PAYMENT_REQUEST_CODE) + lifecycleScope.launch { + val opt = createPaymentOptions() + opt.setTerminalParams( + TerminalsManager.selectedTerminal.terminalKey, + TerminalsManager.selectedTerminal.publicKey + ) + if (settings.isEnableCombiInit) { + showProgressDialog() + runCatching { combInitDelegate.sendInit(opt).paymentId!! } + .onFailure { + hideProgressDialog() + showErrorDialog() + } + .onSuccess { + hideProgressDialog() + spbPayment.launch(TinkoffAcquiring.SbpScreen.StartData(it,opt)) + } + } else { + spbPayment.launch(TinkoffAcquiring.SbpScreen.StartData(opt)) + } + } } protected fun setupTinkoffPay() { diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/CombInitDelegate.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/CombInitDelegate.kt new file mode 100644 index 00000000..1634d671 --- /dev/null +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/CombInitDelegate.kt @@ -0,0 +1,29 @@ +package ru.tinkoff.acquiring.sample.utils + +import kotlinx.coroutines.* +import ru.tinkoff.acquiring.sdk.AcquiringSdk +import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions +import ru.tinkoff.acquiring.sdk.payment.PaymentProcess.Companion.configure +import ru.tinkoff.acquiring.sdk.requests.performSuspendRequest +import ru.tinkoff.acquiring.sdk.responses.InitResponse +import kotlin.coroutines.CoroutineContext + + +/** + * Created by i.golovachev + * + * Имитирует запрос к строннему бекенду мерчанта, в рамках совершения комби-инит платежа + */ +class CombInitDelegate(private val sdk: AcquiringSdk, private val context: CoroutineContext) { + + private val combiInitAdditional = + mapOf("merchant init response field" to "merchant init response value") + + suspend fun sendInit(paymentOptions: PaymentOptions): InitResponse { + paymentOptions.order.additionalData = + paymentOptions.order.additionalData?.plus(combiInitAdditional) ?: combiInitAdditional + return withContext(context) { + sdk.init { configure(paymentOptions) }.performSuspendRequest().getOrThrow() + } + } +} \ No newline at end of file diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/SessionParams.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/SessionParams.kt index 8aa1a70a..a9462338 100644 --- a/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/SessionParams.kt +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/SessionParams.kt @@ -63,6 +63,8 @@ data class SessionParams( SessionParams("1578942570730", PASSWORD, PUBLIC_KEY, DEFAULT_CUSTOMER_KEY, DEFAULT_CUSTOMER_EMAIL, "SBP 3"), SessionParams("1661351612593", "45tnvz0kkyyz82mw", PUBLIC_KEY, DEFAULT_CUSTOMER_KEY, DEFAULT_CUSTOMER_EMAIL, "With token"), SessionParams("1661161705205", null, PUBLIC_KEY, DEFAULT_CUSTOMER_KEY, DEFAULT_CUSTOMER_EMAIL, "Without token"), + SessionParams("1584440932619", "dniplpm7ct3tg9e3", PUBLIC_KEY, DEFAULT_CUSTOMER_KEY, DEFAULT_CUSTOMER_EMAIL, "Sbp pay with token"), + SessionParams("1674123391307", "rpcmn7osqle5sj2r", PUBLIC_KEY, DEFAULT_CUSTOMER_KEY, DEFAULT_CUSTOMER_EMAIL, "new merchant") ) } } diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/SettingsSdkManager.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/SettingsSdkManager.kt index 31b2980e..5257ffd1 100644 --- a/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/SettingsSdkManager.kt +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/SettingsSdkManager.kt @@ -56,6 +56,9 @@ class SettingsSdkManager(private val context: Context) { val yandexPayEnabled: Boolean get() = preferences.getBoolean(context.getString(R.string.acq_sp_yandex_pay), true) + val isEnableCombiInit: Boolean + get() = preferences.getBoolean(context.getString(R.string.acq_sp_combi_init), true) + val checkType: String get() { val defaultCheckType = context.getString(R.string.acq_sp_check_type_no) diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/TerminalsManager.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/TerminalsManager.kt index 8100e513..eb183741 100644 --- a/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/TerminalsManager.kt +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/TerminalsManager.kt @@ -25,7 +25,7 @@ object TerminalsManager { value.find { it.terminalKey == SessionParams.TEST_SDK.terminalKey } != null -> value else -> value.toMutableList().apply { add(0, SessionParams.TEST_SDK) } } - prefs.edit().putString(TERMINALS_KEY, gson.toJson(_terminals)).apply() + prefs.edit().putString(TERMINALS_KEY, gson.toJson(_terminals)).commit() if (terminals.find { it.terminalKey == _selectedTerminalKey } == null) { selectedTerminalKey = terminals.first().terminalKey @@ -37,7 +37,7 @@ object TerminalsManager { get() = _selectedTerminalKey!! set(value) { _selectedTerminalKey = value - prefs.edit().putString(SELECTED_TERMINAL_KEY, value).apply() + prefs.edit().putString(SELECTED_TERMINAL_KEY, value).commit() SampleApplication.initSdk(context, selectedTerminal) } @@ -65,7 +65,10 @@ object TerminalsManager { selectedTerminalKey = terminals.first().terminalKey } - fun findTerminal(terminalKey: String) = terminals.find { it.terminalKey == terminalKey } + fun findTerminal(terminalKey: String): SessionParams? { + val terminal = terminals.find { it.terminalKey == terminalKey } + return terminal + } fun requireTerminal(terminalKey: String) = findTerminal(terminalKey)!! } \ No newline at end of file diff --git a/sample/src/main/res/values-ru/strings.xml b/sample/src/main/res/values-ru/strings.xml index e5759b56..f3545813 100644 --- a/sample/src/main/res/values-ru/strings.xml +++ b/sample/src/main/res/values-ru/strings.xml @@ -75,6 +75,7 @@ Темная тема CheckType Модуль камеры + Combi - Init Невозможно завершить привязку карты Привязка карты была отменена diff --git a/sample/src/main/res/values/preferences_keys.xml b/sample/src/main/res/values/preferences_keys.xml index 7e2ec691..dec68386 100644 --- a/sample/src/main/res/values/preferences_keys.xml +++ b/sample/src/main/res/values/preferences_keys.xml @@ -22,6 +22,7 @@ acq_sp_recurrent_payment acq_sp_android_pay acq_sp_yandex_pay + acq_sp_combi_init acq_sp_fps acq_sp_tinkoff_pay acq_sp_handle_cards_error diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml index eb5af723..4ecebaa8 100644 --- a/sample/src/main/res/values/strings.xml +++ b/sample/src/main/res/values/strings.xml @@ -74,6 +74,7 @@ Dark Mode CheckType Camera module + Combi - Init Attachment failed. Try later. Attachment canceled diff --git a/sample/src/main/res/xml/settings.xml b/sample/src/main/res/xml/settings.xml index bc6f177a..6254745f 100644 --- a/sample/src/main/res/xml/settings.xml +++ b/sample/src/main/res/xml/settings.xml @@ -63,6 +63,12 @@ android:title="@string/settings_title_yandex_pay" app:iconSpaceReserved="false" /> + + diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt index f320f936..4c2076ac 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt @@ -20,9 +20,12 @@ import android.app.Activity import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.os.Parcelable import androidx.activity.result.contract.ActivityResultContract +import androidx.annotation.MainThread import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment +import kotlinx.android.parcel.Parcelize import kotlinx.coroutines.* import ru.tinkoff.acquiring.sdk.localization.LocalizationSource import ru.tinkoff.acquiring.sdk.models.* @@ -32,10 +35,11 @@ import ru.tinkoff.acquiring.sdk.models.paysources.AttachedCard import ru.tinkoff.acquiring.sdk.models.paysources.CardData import ru.tinkoff.acquiring.sdk.models.paysources.GooglePay import ru.tinkoff.acquiring.sdk.payment.PaymentProcess +import ru.tinkoff.acquiring.sdk.payment.SbpPaymentProcess import ru.tinkoff.acquiring.sdk.requests.performSuspendRequest -import ru.tinkoff.acquiring.sdk.responses.GetTerminalPayMethodsResponse import ru.tinkoff.acquiring.sdk.responses.TerminalInfo import ru.tinkoff.acquiring.sdk.redesign.cards.list.ui.CardsListActivity +import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.SbpPaymentActivity import ru.tinkoff.acquiring.sdk.responses.TinkoffPayStatusResponse import ru.tinkoff.acquiring.sdk.ui.activities.* import ru.tinkoff.acquiring.sdk.ui.activities.AttachCardActivity @@ -177,10 +181,25 @@ class TinkoffAcquiring( * @param options настройки платежной сессии * @param requestCode код для получения результата, по завершению работы SDK */ + @Deprecated("registerForActivityResult(SbpScreen.Contract) { result -> }.launch(options)", + ReplaceWith("registerForActivityResult(SbpScreen.Contract) { cardId ->\n" + + " // handle result\n" + + "}.launch(attachCardOptions {\n" + + " //setup options\n" + + "})")) fun payWithSbp(activity: Activity, options: PaymentOptions, requestCode: Int) { openPaymentScreen(activity, options, requestCode, FpsState) } + /** + * Контракт оплаты через Систему быстрых платежей + */ + @MainThread + fun payWithSbpContract(): SbpScreen.Contract { + SbpPaymentProcess.init(sdk, applicationContext.packageManager) + return SbpScreen.Contract() + } + /** * Запуск SDK для оплаты через Систему быстрых платежей * @@ -188,6 +207,12 @@ class TinkoffAcquiring( * @param options настройки платежной сессии * @param requestCode код для получения результата, по завершению работы SDK */ + @Deprecated("registerForActivityResult(SbpScreen.Contract) { result -> }.launch(options)", + ReplaceWith("registerForActivityResult(SbpScreen.Contract) { cardId ->\n" + + " // handle result\n" + + "}.launch(attachCardOptions {\n" + + " //setup options\n" + + "})")) fun payWithSbp(fragment: Fragment, options: PaymentOptions, requestCode: Int) { openPaymentScreen(fragment, options, requestCode, FpsState) } @@ -543,6 +568,44 @@ class TinkoffAcquiring( } } + object SbpScreen { + + sealed class Result + class Success(val payment: Long) : Result() + class Canceled : Result() + class Error(val error: Throwable) : Result() + class NoBanks() : Result() + + @Parcelize + class StartData private constructor( + val paymentOptions: PaymentOptions, val paymentId: Long? + ) : Parcelable { + + // для обычного платежа + constructor(paymentOptions: PaymentOptions) : this(paymentOptions, null) + + // если вызов init был на стороне вашего сервера + constructor(paymentId: Long, paymentOptions: PaymentOptions) : this(paymentOptions, paymentId) + } + + class Contract internal constructor(): ActivityResultContract() { + + override fun createIntent(context: Context, data: StartData): Intent = + Intent(context, SbpPaymentActivity::class.java).apply { + putExtra(SbpPaymentActivity.EXTRA_PAYMENT_DATA, data) + } + + override fun parseResult(resultCode: Int, intent: Intent?): Result = when (resultCode) { + AppCompatActivity.RESULT_OK -> Success( + intent!!.getLongExtra(SbpPaymentActivity.EXTRA_PAYMENT_ID, 0), + ) + TinkoffAcquiring.RESULT_ERROR -> Error(intent!!.getSerializableExtra(TinkoffAcquiring.EXTRA_ERROR)!! as Throwable) + SbpPaymentActivity.SBP_BANK_RESULT_CODE_NO_BANKS -> NoBanks() + else -> Canceled() + } + } + } + companion object { const val RESULT_ERROR = 500 diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/models/NspkRequest.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/models/NspkRequest.kt index 5005f89a..0b321a82 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/models/NspkRequest.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/models/NspkRequest.kt @@ -16,13 +16,17 @@ package ru.tinkoff.acquiring.sdk.models +import kotlinx.coroutines.CompletableDeferred import ru.tinkoff.acquiring.sdk.utils.NspkClient import ru.tinkoff.acquiring.sdk.utils.Request +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine /** * @author Mariya Chernyadieva */ -internal class NspkRequest: Request { +internal class NspkRequest : Request { @Volatile private var disposed = false @@ -39,4 +43,13 @@ internal class NspkRequest: Request { val client = NspkClient() client.call(this, onSuccess, onFailure) } + + suspend fun execute(): NspkResponse { + return suspendCoroutine { continuation -> + execute( + onSuccess = { continuation.resume(it) }, + onFailure = { continuation.resumeWithException(it) } + ) + } + } } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentState.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentState.kt index f21ca6f2..63d20e27 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentState.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentState.kt @@ -16,7 +16,6 @@ package ru.tinkoff.acquiring.sdk.payment -import ru.tinkoff.acquiring.sdk.models.AsdkState import ru.tinkoff.acquiring.sdk.models.ThreeDsState /** @@ -45,12 +44,14 @@ enum class PaymentState { sealed interface YandexPaymentState { object Created : YandexPaymentState object Started : YandexPaymentState - class Registred(val paymentId: Long): YandexPaymentState + class Registred(val paymentId: Long) : YandexPaymentState object ThreeDsRejected : YandexPaymentState class ThreeDsUiNeeded(val asdkState: ThreeDsState) : YandexPaymentState class Error(val paymentId: Long?, val throwable: Throwable) : YandexPaymentState - class Success(val paymentId: Long,val cardId: String?, val rebillId: String?) : YandexPaymentState + class Success(val paymentId: Long, val cardId: String?, val rebillId: String?) : + YandexPaymentState + object Stopped : YandexPaymentState } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/SbpPaymentProcess.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/SbpPaymentProcess.kt new file mode 100644 index 00000000..4e412beb --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/SbpPaymentProcess.kt @@ -0,0 +1,198 @@ +package ru.tinkoff.acquiring.sdk.payment + +import android.content.pm.PackageManager +import androidx.annotation.MainThread +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import ru.tinkoff.acquiring.sdk.AcquiringSdk +import ru.tinkoff.acquiring.sdk.exceptions.AcquiringSdkException +import ru.tinkoff.acquiring.sdk.models.NspkRequest +import ru.tinkoff.acquiring.sdk.models.enums.DataTypeQr +import ru.tinkoff.acquiring.sdk.models.enums.ResponseStatus +import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions +import ru.tinkoff.acquiring.sdk.payment.PaymentProcess.Companion.configure +import ru.tinkoff.acquiring.sdk.redesign.sbp.util.NspkBankAppsProvider +import ru.tinkoff.acquiring.sdk.redesign.sbp.util.NspkInstalledAppsChecker +import ru.tinkoff.acquiring.sdk.redesign.sbp.util.SbpHelper +import ru.tinkoff.acquiring.sdk.requests.performSuspendRequest + +/** + * Created by i.golovachev + */ +class SbpPaymentProcess internal constructor( + private val sdk: AcquiringSdk, + private val bankAppsProvider: NspkInstalledAppsChecker, + private val nspkBankProvider: NspkBankAppsProvider, + private val scope: CoroutineScope +) { + internal constructor( + sdk: AcquiringSdk, + bankAppsProvider: NspkInstalledAppsChecker, + nspkBankAppsProvider: NspkBankAppsProvider, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO + ) : this(sdk, bankAppsProvider, nspkBankAppsProvider, CoroutineScope(ioDispatcher)) + + val state = MutableStateFlow(SbpPaymentState.Created) + private var looperJob: Job = Job() + + fun start(paymentOptions: PaymentOptions, paymentId: Long? = null) { + scope.launch { + runOrCatch { + val nspkApps = + nspkBankProvider.getNspkApps() + val id = paymentId ?: sendInit(paymentOptions).paymentId!! + state.value = SbpPaymentState.Started(id) + val deeplink = sendGetQr(paymentId) + + val installedApps = + bankAppsProvider.checkInstalledApps(nspkApps, deeplink) + state.value = + SbpPaymentState.NeedChooseOnUi(id, installedApps, deeplink) + } + } + } + + fun goingToBankApp() { + val _state = state.value + when (_state) { + is SbpPaymentState.NeedChooseOnUi -> { + state.value = SbpPaymentState.LeaveOnBankApp(_state.paymentId) + } + is SbpPaymentState.Stopped -> { + state.value = SbpPaymentState.LeaveOnBankApp(_state.paymentId!!) + } + } + } + + fun startCheckingStatus(retriesCount: Int = 10) { + // выйдем из функции если стейт уже проверяется или вызов некорректен + val _state = state.value + if (_state is SbpPaymentState.LeaveOnBankApp) { + looperJob = scope.launch { + StatusLooper(_state.paymentId!!, sdk, state).start(retriesCount) + } + } + } + + fun stop() { + state.value = SbpPaymentState.Stopped(state.value.paymentId) + if (looperJob.isActive) { + looperJob.cancel() + } + } + + private suspend fun runOrCatch(block: suspend () -> Unit) = try { + block() + } catch (throwable: Throwable) { + state.update { + if (throwable is CancellationException) { + SbpPaymentState.Stopped(it.paymentId) + } else { + SbpPaymentState.GetBankListFailed(it.paymentId, throwable) + } + } + } + + private suspend fun sendInit(paymentOptions: PaymentOptions) = + sdk.init { configure(paymentOptions) }.performSuspendRequest().getOrThrow() + + private suspend fun sendGetQr(paymentId: Long?) = checkNotNull( + sdk.getQr { + this.paymentId = paymentId + this.dataType = DataTypeQr.PAYLOAD + }.performSuspendRequest().getOrThrow().data, + ) { "data from NSPK are null" } + + class StatusLooper( + private val _paymentId: Long, + private val sdk: AcquiringSdk, + private val state: MutableStateFlow, + ) { + suspend fun start(retriesCount: Int) { + var tries = 0 + while (retriesCount > tries) { + val response = + sdk.getState { this.paymentId = _paymentId }.performSuspendRequest() + .getOrThrow() + delay(LOOPER_DELAY_MS) + val status = response.status + when (status) { + ResponseStatus.AUTHORIZED, ResponseStatus.CONFIRMED -> { + state.value = SbpPaymentState.Success( + _paymentId, null, null + ) + return + } + ResponseStatus.REJECTED -> { + state.value = SbpPaymentState.PaymentFailed( + _paymentId, + AcquiringSdkException(IllegalStateException("PaymentState = $status")) + ) + return + } + else -> { + tries += 1 + state.value = + SbpPaymentState.CheckingStatus(_paymentId, response.status) + } + } + } + state.value = SbpPaymentState.PaymentFailed( + _paymentId, + AcquiringSdkException(IllegalStateException("retriesCount is over")) + ) + } + } + + companion object { + private const val LOOPER_DELAY_MS = 3000L + private var instance: SbpPaymentProcess? = null + + @MainThread + fun init( + sdk: AcquiringSdk, + packageManager: PackageManager, + bankAppsProvider: NspkInstalledAppsChecker = NspkInstalledAppsChecker { nspkBanks, dl -> + SbpHelper.getBankApps(packageManager, dl, nspkBanks) + }, + nspkBankAppsProvider: NspkBankAppsProvider = NspkBankAppsProvider { + NspkRequest().execute().banks + } + ) { + instance?.scope?.cancel() + instance = SbpPaymentProcess(sdk, bankAppsProvider, nspkBankAppsProvider) + } + + fun get() = instance!! + } +} + +sealed interface SbpPaymentState { + val paymentId: Long? + + object Created : SbpPaymentState { + override val paymentId: Long? = null + } + + class Started(override val paymentId: Long) : SbpPaymentState + class NeedChooseOnUi( + override val paymentId: Long, + val bankList: List, + val deeplink: String + ) : SbpPaymentState + + class GetBankListFailed(override val paymentId: Long?, val throwable: Throwable) : + SbpPaymentState + + class LeaveOnBankApp(override val paymentId: Long) : SbpPaymentState + class CheckingStatus( + override val paymentId: Long, + val status: ResponseStatus? + ) : SbpPaymentState + + class PaymentFailed(override val paymentId: Long?, val throwable: Throwable) : SbpPaymentState + class Success(override val paymentId: Long, val cardId: String?, val rebillId: String?) : + SbpPaymentState + + class Stopped(override val paymentId: Long?) : SbpPaymentState +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/YandexPaymentProcess.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/YandexPaymentProcess.kt index 0c814eb1..3ab6bd56 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/YandexPaymentProcess.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/YandexPaymentProcess.kt @@ -77,7 +77,7 @@ class YandexPaymentProcess( sendToListener(YandexPaymentState.Stopped) } - private fun sendToListener(state: YandexPaymentState?) { + private fun sendToListener(state: YandexPaymentState?) { this._state.update { state } } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentFailureInsufficientFundsDialogFragment.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentFailureInsufficientFundsDialogFragment.kt deleted file mode 100644 index 41074520..00000000 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentFailureInsufficientFundsDialogFragment.kt +++ /dev/null @@ -1,48 +0,0 @@ -package ru.tinkoff.acquiring.sdk.redesign.dialog - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import ru.tinkoff.acquiring.sdk.R -import ru.tinkoff.acquiring.sdk.ui.customview.LoaderButton -import ru.tinkoff.acquiring.sdk.utils.lazyView - -class PaymentFailureInsufficientFundsDialogFragment : BottomSheetDialogFragment() { - - private val buttonBackToPayment: LoaderButton by lazyView(R.id.acq_button_back_to_payment) - private val buttonOk: LoaderButton by lazyView(R.id.acq_button_ok) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setStyle(STYLE_NO_FRAME, theme) - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = - inflater.inflate(R.layout.acq_fragment_payment_failure_insufficient_funds, container, false) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - buttonBackToPayment.setOnClickListener { onBackToPayment() } - buttonOk.setOnClickListener { onOk() } - } - - private fun onBackToPayment() { - ((parentFragment as? OnBackToPayment) ?: (activity as? OnBackToPayment)) - ?.onPaymentFailureBackToPayment(this) - } - - private fun onOk() { - ((parentFragment as? OnOk) ?: (activity as? OnOk))?.onPaymentFailureOk(this) - } - - fun interface OnBackToPayment { - fun onPaymentFailureBackToPayment(fragment: PaymentFailureInsufficientFundsDialogFragment) - } - - fun interface OnOk { - fun onPaymentFailureOk(fragment: PaymentFailureInsufficientFundsDialogFragment) - } -} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusFormExt.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusFormExt.kt new file mode 100644 index 00000000..26d4ad09 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusFormExt.kt @@ -0,0 +1,47 @@ +package ru.tinkoff.acquiring.sdk.redesign.dialog + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import ru.tinkoff.acquiring.sdk.R + +interface OnPaymentSheetCloseListener { + fun onClose(state: PaymentSheetStatus) +} + +fun T.createPaymentSheetWrapper(): PaymentStatusSheet where T : FragmentActivity, T : OnPaymentSheetCloseListener { + return PaymentStatusSheet() +} + +fun T.createPaymentSheetWrapper(): PaymentStatusSheet where T : Fragment, T : OnPaymentSheetCloseListener { + return PaymentStatusSheet() +} + +sealed class PaymentSheetStatus( + open val title: Int?, + open val subtitle: Int? = null, + open val mainButton: Int? = null, + open val secondButton: Int? = null +) { + + object NotYet : PaymentSheetStatus(null) + + data class Progress( + override val title: Int, + override val subtitle: Int? = null, + override val secondButton: Int? = null + ) : PaymentSheetStatus(title, subtitle, null, secondButton) + + class Error( + title: Int, subtitle: Int? = null, mainButton: Int? = null, + secondButton: Int? = null, val throwable: Throwable + ) : PaymentSheetStatus(title, subtitle, mainButton, secondButton) + + class Success( + title: Int = R.string.acq_commonsheet_paid_title, + subtitle: Int? = null, + mainButton: Int? = R.string.acq_commonsheet_clear_primarybutton, + val paymentId: Long + ) : PaymentSheetStatus(title, subtitle, mainButton) + + object Hide : PaymentSheetStatus(null) +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusSheet.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusSheet.kt new file mode 100644 index 00000000..8b5d5c8e --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusSheet.kt @@ -0,0 +1,133 @@ +package ru.tinkoff.acquiring.sdk.redesign.dialog + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.core.view.isVisible +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.progressindicator.CircularProgressIndicator +import ru.tinkoff.acquiring.sdk.R +import ru.tinkoff.acquiring.sdk.ui.customview.LoaderButton + +class PaymentStatusSheet internal constructor(): BottomSheetDialogFragment() { + private lateinit var icon: ImageView + private lateinit var progress: CircularProgressIndicator + private lateinit var title: TextView + private lateinit var subtitle: TextView + private lateinit var mainButton: LoaderButton + private lateinit var secondButton: LoaderButton + + @Suppress("UNCHECKED_CAST") + private val onCloseListener: OnPaymentSheetCloseListener + get() { + val listener = (parentFragment as? OnPaymentSheetCloseListener) + ?: (activity as? OnPaymentSheetCloseListener) + return checkNotNull(listener) { + "parent of fragment not implemented OnPaymentSheetCloseListener" + } + } + + var state: PaymentSheetStatus? = null + set(value) { + field = value + if (value != null && isResumed) { + showState(value) + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + setStyle(STYLE_NO_FRAME, R.style.BottomSheetDialog) + return super.onCreateDialog(savedInstanceState) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? = + inflater.inflate(R.layout.acq_payment_status_form, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + dialog?.window?.attributes?.windowAnimations = R.style.AcqBottomSheetAnim + icon = view.findViewById(R.id.acq_payment_status_form_icon) + progress = view.findViewById(R.id.acq_payment_status_formm_progress) + title = view.findViewById(R.id.acq_payment_status_form_title) + subtitle = view.findViewById(R.id.acq_payment_status_form_subtitle) + mainButton = view.findViewById(R.id.acq_payment_status_form_main_button) + secondButton = view.findViewById(R.id.acq_payment_status_form_second_button) + state?.let(::showState) + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + state?.let { + onCloseListener.onClose(it) + } + } + + private fun set( + icon: Int?, + title: Int?, + subtitle: Int?, + mainButton: Int?, + secondButton: Int?, + progress: Boolean = icon == null, + isCancelable: Boolean = progress.not() && secondButton == null + ) { + if (icon != null) + this.icon.setImageResource(icon) + + this.icon.isVisible = icon != null + + if (title != null) + this.title.setText(title) + + this.title.isVisible = title != null + + if (subtitle != null) + this.subtitle.setText(subtitle) + + this.subtitle.isVisible = title != null + + if (mainButton != null) + this.mainButton.text = getString(mainButton) + + this.mainButton.isVisible = mainButton != null + + if (secondButton != null) + this.secondButton.text = getString(secondButton) + + this.secondButton.isVisible = secondButton != null + + this.progress.isVisible = progress + + this.isCancelable = isCancelable + } + + private fun showState(state: PaymentSheetStatus) { + set( + icon = defineIcon(state), + title = state.title, + subtitle = state.subtitle, + mainButton = state.mainButton, + secondButton = state.secondButton, + ) + + this.mainButton.setOnClickListener { onCloseListener.onClose(state) } + this.secondButton.setOnClickListener { onCloseListener.onClose(state) } + } + + private fun defineIcon(state: PaymentSheetStatus) = when (state) { + is PaymentSheetStatus.Error -> R.drawable.acq_ic_cross_circle + is PaymentSheetStatus.NotYet -> null + is PaymentSheetStatus.Progress -> null + is PaymentSheetStatus.Hide -> null + is PaymentSheetStatus.Success -> R.drawable.acq_ic_check_circle_positive + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/BankListViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/BankListViewModel.kt deleted file mode 100644 index 61972597..00000000 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/BankListViewModel.kt +++ /dev/null @@ -1,57 +0,0 @@ -package ru.tinkoff.acquiring.sdk.redesign.sbp.ui - -import androidx.lifecycle.ViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import ru.tinkoff.acquiring.sdk.models.NspkRequest -import ru.tinkoff.acquiring.sdk.models.NspkResponse -import ru.tinkoff.acquiring.sdk.utils.ConnectionChecker -import ru.tinkoff.acquiring.sdk.utils.CoroutineManager - -internal class BankListViewModel( - private val bankAppsProvider: BankAppsProvider, - private val connectionChecker: ConnectionChecker, - private val manager: CoroutineManager = CoroutineManager() -) : ViewModel() { - - val stateUiFlow = MutableStateFlow(BankListState.Shimmer) - - fun loadData() { - if (connectionChecker.isOnline().not()) { - stateUiFlow.tryEmit(BankListState.NoNetwork) - return - } - stateUiFlow.tryEmit(BankListState.Shimmer) - manager.launchOnBackground { - manager.call(NspkRequest(), - onSuccess = this@BankListViewModel::handleGetBankListResponse, - onFailure = this@BankListViewModel::handleGetBankListError) - } - } - - @Suppress("UNCHECKED_CAST") - private fun handleGetBankListResponse(nspk: NspkResponse) { - try { - val banks = bankAppsProvider.getBankApps(nspk.banks) - stateUiFlow.value = if (banks.isEmpty()) { - BankListState.Empty - } else { - BankListState.Empty - } - } catch (e: Exception) { - handleGetBankListError(e) - } - } - - private fun handleGetBankListError(it: Exception) { - stateUiFlow.value = BankListState.Error(it) - } - - override fun onCleared() { - manager.cancelAll() - super.onCleared() - } - - fun interface BankAppsProvider { - fun getBankApps(nspkBanks: Set): List - } -} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/BankListActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentActivity.kt similarity index 50% rename from ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/BankListActivity.kt rename to ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentActivity.kt index 78f8558e..5ca8ec98 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/BankListActivity.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentActivity.kt @@ -17,7 +17,6 @@ package ru.tinkoff.acquiring.sdk.redesign.sbp.ui import android.annotation.SuppressLint -import android.content.Context import android.content.Intent import android.os.Bundle import android.view.LayoutInflater @@ -27,26 +26,36 @@ import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import android.widget.ViewFlipper -import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.core.view.children import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import ru.tinkoff.acquiring.sdk.R import ru.tinkoff.acquiring.sdk.TinkoffAcquiring import ru.tinkoff.acquiring.sdk.redesign.common.util.AcqShimmerAnimator -import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.BankListActivity.Companion.SBP_BANK_RESULT_CODE_NO_BANKS -import ru.tinkoff.acquiring.sdk.redesign.sbp.util.SbpHelper +import ru.tinkoff.acquiring.sdk.redesign.dialog.* +import ru.tinkoff.acquiring.sdk.redesign.sbp.util.SbpHelper.openSbpDeeplink import ru.tinkoff.acquiring.sdk.utils.ConnectionChecker +import ru.tinkoff.acquiring.sdk.utils.lazyUnsafe import ru.tinkoff.acquiring.sdk.utils.lazyView import ru.tinkoff.acquiring.sdk.utils.showById -internal class BankListActivity : AppCompatActivity() { +internal class SbpPaymentActivity : AppCompatActivity(), OnPaymentSheetCloseListener { - private lateinit var viewModel: BankListViewModel + private val startData: TinkoffAcquiring.SbpScreen.StartData by lazyUnsafe { + intent.getParcelableExtra(EXTRA_PAYMENT_DATA)!! + } + + private val viewModel: SbpPaymentViewModel by viewModels { + SbpPaymentViewModel.factory( + ConnectionChecker(application), + ) + } + + private val statusFragment: PaymentStatusSheet = createPaymentSheetWrapper() private val recyclerView: RecyclerView by lazyView(R.id.acq_bank_list_content) private val cardShimmer: LinearLayout by lazyView(R.id.acq_bank_list_shimmer) @@ -57,7 +66,6 @@ internal class BankListActivity : AppCompatActivity() { private val stubButtonView: TextView by lazyView(R.id.acq_stub_retry_button) private lateinit var deeplink: String - private var banks: List? = null @SuppressLint("NotifyDataSetChanged") set(value) { @@ -68,18 +76,21 @@ internal class BankListActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.acq_activity_bank_list) - deeplink = intent.getStringExtra(EXTRA_DEEPLINK)!! - viewModel = BankListViewModel({ nspkBanks -> - SbpHelper.getBankApps(packageManager, deeplink, nspkBanks) - }, ConnectionChecker(application)) - viewModel.loadData() + if (savedInstanceState == null) { + viewModel.loadData(startData.paymentOptions, startData.paymentId) + } initToolbar() initViews() subscribeOnState() } + override fun onResume() { + super.onResume() + viewModel.startCheckingStatus() + } + private fun initToolbar() { setSupportActionBar(findViewById(R.id.acq_toolbar)) supportActionBar?.setDisplayHomeAsUpEnabled(true) @@ -93,69 +104,92 @@ internal class BankListActivity : AppCompatActivity() { } override fun onBackPressed() { + viewModel.cancelPayment() setResult(RESULT_CANCELED) finish() } + override fun onClose(status: PaymentSheetStatus) { + when (status) { + is PaymentSheetStatus.Error -> finishWithError(status.throwable) + is PaymentSheetStatus.Progress -> { + viewModel.cancelPayment() + statusFragment.dismiss() + } + is PaymentSheetStatus.Success -> finishWithResult(status.paymentId) + else -> Unit + } + } + private fun initViews() { recyclerView.adapter = Adapter() } private fun subscribeOnState() { - lifecycleScope.launch { - subscribeOnUiState() - } + lifecycleScope.launch { subscribeOnUiState() } + lifecycleScope.launch { subscribeOnSheetState() } } - private fun CoroutineScope.subscribeOnUiState() { - launch { - viewModel.stateUiFlow.collectLatest { - when (it) { - is BankListState.Content -> { - viewFlipper.showById(R.id.acq_bank_list_content) - banks = it.banks - } - is BankListState.Shimmer -> { - viewFlipper.showById(R.id.acq_bank_list_shimmer) - AcqShimmerAnimator.animateSequentially( - cardShimmer.children.toList() - ) - } - is BankListState.Error -> { - showStub( - imageResId = R.drawable.acq_ic_generic_error_stub, - titleTextRes = R.string.acq_generic_alert_label, - subTitleTextRes = R.string.acq_generic_stub_description, - buttonTextRes = R.string.acq_generic_alert_access - ) - stubButtonView.setOnClickListener { _ -> finishWithError(it.throwable) } - } - is BankListState.NoNetwork -> { - showStub( - imageResId = R.drawable.acq_ic_no_network, - titleTextRes = R.string.acq_generic_stubnet_title, - subTitleTextRes = R.string.acq_generic_stubnet_description, - buttonTextRes = R.string.acq_generic_button_stubnet - ) - stubButtonView.setOnClickListener { - viewModel.loadData() - } - } - is BankListState.Empty -> { - setResult(SBP_BANK_RESULT_CODE_NO_BANKS) - finish() + private suspend fun subscribeOnUiState() { + viewModel.stateUiFlow.collectLatest { + when (it) { + is SpbBankListState.Content -> { + viewFlipper.showById(R.id.acq_bank_list_content) + banks = it.banks + deeplink = it.deeplink + } + is SpbBankListState.Shimmer -> { + viewFlipper.showById(R.id.acq_bank_list_shimmer) + AcqShimmerAnimator.animateSequentially( + cardShimmer.children.toList() + ) + } + is SpbBankListState.Error -> { + showStub( + imageResId = R.drawable.acq_ic_generic_error_stub, + titleTextRes = R.string.acq_generic_alert_label, + subTitleTextRes = R.string.acq_generic_stub_description, + buttonTextRes = R.string.acq_generic_alert_access + ) + stubButtonView.setOnClickListener { _ -> finishWithError(it.throwable) } + } + is SpbBankListState.NoNetwork -> { + showStub( + imageResId = R.drawable.acq_ic_no_network, + titleTextRes = R.string.acq_generic_stubnet_title, + subTitleTextRes = R.string.acq_generic_stubnet_description, + buttonTextRes = R.string.acq_generic_button_stubnet + ) + stubButtonView.setOnClickListener { + viewModel.loadData(startData.paymentOptions, startData.paymentId) } } + is SpbBankListState.Empty -> { + setResult(SBP_BANK_RESULT_CODE_NO_BANKS) + finish() + } } } } - private fun onBankSelected(packageName: String) { - setResult(RESULT_OK, Intent().apply { - putExtra(EXTRA_DEEPLINK, deeplink) - putExtra(EXTRA_PACKAGE_NAME, packageName) - }) - finish() + private suspend fun subscribeOnSheetState() { + viewModel.paymentStateFlow.collect { + statusFragment.state = it + when (it) { + is PaymentSheetStatus.Hide -> if (statusFragment.isAdded) { + statusFragment.dismiss() + } + is PaymentSheetStatus.NotYet -> Unit + else -> if (statusFragment.isAdded.not()) { + statusFragment.show(supportFragmentManager, null) + } + } + } + } + + private fun onBankSelected(packageName: String, deeplink: String) { + viewModel.onGoingToBankApp() + openSbpDeeplink(deeplink, packageName, this) } private fun showStub( @@ -177,6 +211,13 @@ internal class BankListActivity : AppCompatActivity() { stubButtonView.setText(buttonTextRes) } + private fun finishWithResult(paymentId: Long) { + val intent = Intent() + intent.putExtra(TinkoffAcquiring.EXTRA_PAYMENT_ID, paymentId) + setResult(RESULT_OK, intent) + finish() + } + private fun finishWithError(throwable: Throwable) { setErrorResult(throwable) finish() @@ -191,10 +232,14 @@ internal class BankListActivity : AppCompatActivity() { inner class Adapter : RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH = - VH(LayoutInflater.from(this@BankListActivity).inflate( - R.layout.acq_bank_list_item, parent, false)) + VH( + LayoutInflater.from(this@SbpPaymentActivity).inflate( + R.layout.acq_bank_list_item, parent, false + ) + ) - override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(banks!![position]) + override fun onBindViewHolder(holder: VH, position: Int) = + holder.bind(banks!![position], deeplink) override fun getItemCount(): Int = banks?.size ?: 0 } @@ -204,57 +249,30 @@ internal class BankListActivity : AppCompatActivity() { private val logo = view.findViewById(R.id.acq_bank_list_item_logo) private val name = view.findViewById(R.id.acq_bank_list_item_name) - fun bind(packageName: String) { + fun bind(packageName: String, deeplink: String) { logo.setImageDrawable(packageManager.getApplicationIcon(packageName)) name.text = packageManager.getApplicationLabel( - packageManager.getApplicationInfo(packageName, 0)) + packageManager.getApplicationInfo(packageName, 0) + ) itemView.setOnClickListener { - onBankSelected(packageName) + onBankSelected(packageName, deeplink) } } } companion object { - internal const val EXTRA_DEEPLINK = "extra_deeplink" - internal const val EXTRA_PACKAGE_NAME = "extra_package_name" + internal const val EXTRA_PAYMENT_ID = "extra_payment_id" + internal const val EXTRA_PAYMENT_DATA = "extra_payment_data" internal const val SBP_BANK_RESULT_CODE_NO_BANKS = 501 } } -sealed class BankListState { - object Shimmer : BankListState() - object Empty : BankListState() - class Error(val throwable: Throwable) : BankListState() - object NoNetwork : BankListState() - - class Content(val banks: List) : BankListState() -} - -object BankList { - - sealed class Result - class Success(val deeplink: String, val packageName: String) : Result() - class Canceled : Result() - class Error(val error: Throwable) : Result() - class NoBanks() : Result() - - - object Contract : ActivityResultContract() { - - override fun createIntent(context: Context, deeplink: String): Intent = - Intent(context, BankListActivity::class.java).apply { - putExtra(BankListActivity.EXTRA_DEEPLINK, deeplink) - } - - override fun parseResult(resultCode: Int, intent: Intent?): Result = when (resultCode) { - AppCompatActivity.RESULT_OK -> Success( - intent!!.getStringExtra(BankListActivity.EXTRA_DEEPLINK)!!, - intent.getStringExtra(BankListActivity.EXTRA_PACKAGE_NAME)!!) - TinkoffAcquiring.RESULT_ERROR -> Error(intent!!.getSerializableExtra(TinkoffAcquiring.EXTRA_ERROR)!! as Throwable) - SBP_BANK_RESULT_CODE_NO_BANKS -> NoBanks() - else -> Canceled() - } - } +sealed class SpbBankListState { + object Shimmer : SpbBankListState() + object Empty : SpbBankListState() + class Error(val throwable: Throwable) : SpbBankListState() + object NoNetwork : SpbBankListState() + class Content(val banks: List, val deeplink: String) : SpbBankListState() } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentViewModel.kt new file mode 100644 index 00000000..c2bed00d --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentViewModel.kt @@ -0,0 +1,67 @@ +package ru.tinkoff.acquiring.sdk.redesign.sbp.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import kotlinx.coroutines.flow.MutableStateFlow +import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions +import ru.tinkoff.acquiring.sdk.payment.SbpPaymentProcess +import ru.tinkoff.acquiring.sdk.redesign.dialog.PaymentSheetStatus +import ru.tinkoff.acquiring.sdk.redesign.sbp.util.SbpStateMapper +import ru.tinkoff.acquiring.sdk.utils.ConnectionChecker +import ru.tinkoff.acquiring.sdk.utils.CoroutineManager +import ru.tinkoff.acquiring.sdk.utils.updateIfNotNull + +internal class SbpPaymentViewModel( + private val connectionChecker: ConnectionChecker, + private val sbpPaymentProcess: SbpPaymentProcess, + private val manager: CoroutineManager = CoroutineManager(), + private val stateMapper: SbpStateMapper = SbpStateMapper() +) : ViewModel() { + + val stateUiFlow = MutableStateFlow(SpbBankListState.Shimmer) + val paymentStateFlow = MutableStateFlow(PaymentSheetStatus.NotYet) + + init { + manager.launchOnBackground { + sbpPaymentProcess.state.collect { + stateUiFlow.updateIfNotNull(stateMapper.mapUiState(it)) + paymentStateFlow.updateIfNotNull(stateMapper.mapStatusForm(it)) + } + } + } + + fun loadData(paymentOptions: PaymentOptions, paymentId: Long?) { + if (connectionChecker.isOnline().not()) { + stateUiFlow.value = SpbBankListState.NoNetwork + return + } + stateUiFlow.value = SpbBankListState.Shimmer + sbpPaymentProcess.start(paymentOptions, paymentId) + } + + fun onGoingToBankApp() { + sbpPaymentProcess.goingToBankApp() + } + + fun startCheckingStatus() { + sbpPaymentProcess.startCheckingStatus() + } + + fun cancelPayment() { + sbpPaymentProcess.stop() + } + + override fun onCleared() { + manager.cancelAll() + super.onCleared() + } + + companion object { + fun factory( + connectionChecker: ConnectionChecker, + ) = viewModelFactory { + initializer { SbpPaymentViewModel(connectionChecker, SbpPaymentProcess.get()) } + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/NspkBankAppsProvider.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/NspkBankAppsProvider.kt new file mode 100644 index 00000000..e87623f5 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/NspkBankAppsProvider.kt @@ -0,0 +1,5 @@ +package ru.tinkoff.acquiring.sdk.redesign.sbp.util + +fun interface NspkBankAppsProvider { + suspend fun getNspkApps() : Set +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/NspkInstalledAppsChecker.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/NspkInstalledAppsChecker.kt new file mode 100644 index 00000000..26bbcee6 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/NspkInstalledAppsChecker.kt @@ -0,0 +1,10 @@ +package ru.tinkoff.acquiring.sdk.redesign.sbp.util + +/** + * Created by i.golovachev + */ +fun interface NspkInstalledAppsChecker { + + fun checkInstalledApps(nspkBanks: Set, deeplink: String): List +} + diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpHelper.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpHelper.kt index ed852dc3..ca681d61 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpHelper.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpHelper.kt @@ -5,10 +5,6 @@ import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.FragmentManager -import ru.tinkoff.acquiring.sdk.redesign.dialog.OpenBankProgressDialogFragment -import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.BankListActivity object SbpHelper { diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpStateMapper.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpStateMapper.kt new file mode 100644 index 00000000..1760a6d9 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpStateMapper.kt @@ -0,0 +1,70 @@ +package ru.tinkoff.acquiring.sdk.redesign.sbp.util + +import ru.tinkoff.acquiring.sdk.R +import ru.tinkoff.acquiring.sdk.exceptions.AcquiringSdkTimeoutException +import ru.tinkoff.acquiring.sdk.models.enums.ResponseStatus +import ru.tinkoff.acquiring.sdk.payment.SbpPaymentState +import ru.tinkoff.acquiring.sdk.redesign.dialog.PaymentSheetStatus +import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.SpbBankListState + +/** + * Created by i.golovachev + */ +class SbpStateMapper { + + fun mapUiState(it: SbpPaymentState) = when (it) { + is SbpPaymentState.GetBankListFailed -> SpbBankListState.Error(it.throwable) + is SbpPaymentState.NeedChooseOnUi -> + if (it.bankList.isEmpty()) { + SpbBankListState.Empty + } else { + SpbBankListState.Content(it.bankList, it.deeplink) + } + else -> null + } + + fun mapStatusForm(it: SbpPaymentState): PaymentSheetStatus? { + return when (it) { + is SbpPaymentState.LeaveOnBankApp -> { + PaymentSheetStatus.Progress( + title = R.string.acq_commonsheet_payment_waiting_title, + secondButton = R.string.acq_commonsheet_payment_waiting_flat_button + ) + } + is SbpPaymentState.CheckingStatus -> { + val status = it.status + if (status == ResponseStatus.FORM_SHOWED) { + PaymentSheetStatus.Progress( + title = R.string.acq_commonsheet_payment_waiting_title, + secondButton = R.string.acq_commonsheet_payment_waiting_flat_button + ) + } else { + PaymentSheetStatus.Progress( + title = R.string.acq_commonsheet_processing_title, + subtitle = R.string.acq_commonsheet_processing_description + ) + } + } + is SbpPaymentState.Success -> + PaymentSheetStatus.Success(paymentId = it.paymentId) + is SbpPaymentState.PaymentFailed -> + if (it.throwable is AcquiringSdkTimeoutException) { + PaymentSheetStatus.Error( + title = R.string.acq_commonsheet_timeout_failed_title, + subtitle = R.string.acq_commonsheet_timeout_failed_description, + throwable = it.throwable, + secondButton = R.string.acq_commonsheet_timeout_failed_flat_button + ) + } else { + PaymentSheetStatus.Error( + title = R.string.acq_commonsheet_failed_title, + subtitle = R.string.acq_commonsheet_failed_description, + throwable = it.throwable, + mainButton = R.string.acq_commonsheet_failed_primary_button + ) + } + is SbpPaymentState.Stopped -> PaymentSheetStatus.Hide + else -> null + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt index 0ecc0bda..8eb5655e 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt @@ -26,37 +26,46 @@ import kotlin.coroutines.suspendCoroutine /** * @author Mariya Chernyadieva */ -internal class CoroutineManager(private val exceptionHandler: (Throwable) -> Unit, - private val io: CoroutineDispatcher = IO, - private val main : CoroutineDispatcher = Main) { +internal class CoroutineManager( + private val exceptionHandler: (Throwable) -> Unit, + private val io: CoroutineDispatcher = IO, + private val main: CoroutineDispatcher = Main +) { - constructor(io: CoroutineDispatcher = IO, - main : CoroutineDispatcher = Main) : this({}, io, main) + constructor( + io: CoroutineDispatcher = IO, + main: CoroutineDispatcher = Main + ) : this({}, io, main) private val job = SupervisorJob() - private val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable -> launchOnMain { exceptionHandler(throwable) } } + private val coroutineExceptionHandler = + CoroutineExceptionHandler { _, throwable -> launchOnMain { exceptionHandler(throwable) } } private val coroutineScope = CoroutineScope(Main + coroutineExceptionHandler + job) private val disposableSet = hashSetOf() - fun call(request: Request, onSuccess: (R) -> Unit, onFailure: ((Exception) -> Unit)? = null) { + fun call( + request: Request, + onSuccess: (R) -> Unit, + onFailure: ((Exception) -> Unit)? = null + ) { disposableSet.add(request) launchOnBackground { request.execute( - onSuccess = { - launchOnMain { - onSuccess(it) + onSuccess = { + launchOnMain { + onSuccess(it) + } + }, + onFailure = { + launchOnMain { + if (onFailure == null) { + exceptionHandler.invoke(it) + } else { + onFailure(it) } - }, - onFailure = { - launchOnMain { - if (onFailure == null) { - exceptionHandler.invoke(it) - } else { - onFailure(it) - } - } - }) + } + }) } } @@ -99,4 +108,19 @@ internal class CoroutineManager(private val exceptionHandler: (Throwable) -> Uni block.invoke(this) } } + + fun launchOnBackground( + block: suspend CoroutineScope.() -> Unit, + onError: (Throwable) -> Unit + ): Job { + return coroutineScope.launch(io) { + try { + block.invoke(this) + } catch (e: Throwable) { + if(e is CancellationException) { + onError(e) + } + } + } + } } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/FlowExt.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/FlowExt.kt new file mode 100644 index 00000000..e1d01d3b --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/FlowExt.kt @@ -0,0 +1,11 @@ +package ru.tinkoff.acquiring.sdk.utils + +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Created by i.golovachev + */ +fun MutableStateFlow.updateIfNotNull(value: T?) { + if (value == null) return + this.value = value +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/NspkClient.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/NspkClient.kt index 52883556..506b70d1 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/NspkClient.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/NspkClient.kt @@ -19,12 +19,14 @@ package ru.tinkoff.acquiring.sdk.utils import com.google.gson.Gson import com.google.gson.GsonBuilder import com.google.gson.JsonParseException +import okhttp3.OkHttpClient +import ru.tinkoff.acquiring.sdk.AcquiringSdk import ru.tinkoff.acquiring.sdk.exceptions.NetworkException import ru.tinkoff.acquiring.sdk.models.NspkResponse +import ru.tinkoff.acquiring.sdk.network.AcquiringApi import java.io.IOException -import java.io.InputStreamReader import java.net.HttpURLConnection -import java.net.URL +import java.util.concurrent.TimeUnit /** * @author Mariya Chernyadieva @@ -33,25 +35,35 @@ internal class NspkClient { companion object { private const val NSPK_ANDROID_APPS_URL = "https://qr.nspk.ru/.well-known/assetlinks.json" - private const val STREAM_BUFFER_SIZE = 4096 } - private val gson: Gson = GsonBuilder().create() + private val okHttpClient = OkHttpClient.Builder() + .connectTimeout(40000, TimeUnit.MILLISECONDS) + .readTimeout(40000, TimeUnit.MILLISECONDS) + .build() - fun call(request: Request, onSuccess: (NspkResponse) -> Unit, onFailure: (Exception) -> Unit) { - var responseReader: InputStreamReader? = null + private val gson: Gson = GsonBuilder().create() - try { - val targetUrl = URL(NSPK_ANDROID_APPS_URL) - val connection = targetUrl.openConnection() as HttpURLConnection - connection.requestMethod = "GET" - connection.connect() + fun call( + request: Request, + onSuccess: (NspkResponse) -> Unit, + onFailure: (Exception) -> Unit + ) { - val responseCode = connection.responseCode + val okHttpRequest = okhttp3.Request.Builder().url(NSPK_ANDROID_APPS_URL).get() + .header("User-Agent", System.getProperty("http.agent")!!) + .header("Accept", AcquiringApi.JSON) + .build() + val call = okHttpClient.newCall(okHttpRequest) + AcquiringSdk.log("=== Sending GET request to $NSPK_ANDROID_APPS_URL") + val okHttpResponse = call.execute() + val responseCode = okHttpResponse.code + val response = okHttpResponse.body?.string() + try { + AcquiringSdk.log("=== Got server response code: $responseCode") if (responseCode == HttpURLConnection.HTTP_OK) { - responseReader = InputStreamReader(connection.inputStream) - val response = read(responseReader) + AcquiringSdk.log("=== Got server response: $response") val banks: Set = (gson.fromJson(response, List::class.java) as List).map { ((it as Map<*, *>)["target"] as Map<*, *>)["package_name"] }.toSet() @@ -59,34 +71,22 @@ internal class NspkClient { onSuccess(NspkResponse(banks)) } } else { + AcquiringSdk.log("=== Got server response: $response") if (!request.isDisposed()) { onFailure(NetworkException("Got server error response code $responseCode")) } } } catch (e: IOException) { + AcquiringSdk.log("=== handle error on GET request to $NSPK_ANDROID_APPS_URL") if (!request.isDisposed()) { onFailure(e) } } catch (e: JsonParseException) { + AcquiringSdk.log("=== handle error on GET request to $NSPK_ANDROID_APPS_URL") if (!request.isDisposed()) { onFailure(e) } - } finally { - responseReader?.close() } } - - @Throws(IOException::class) - private fun read(reader: InputStreamReader): String { - val buffer = CharArray(STREAM_BUFFER_SIZE) - var read: Int = -1 - val result = StringBuilder() - - while ({ read = reader.read(buffer, 0, STREAM_BUFFER_SIZE); read }() != -1) { - result.append(buffer, 0, read) - } - - return result.toString() - } } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/QrViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/QrViewModel.kt index 8f005756..80de50f5 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/QrViewModel.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/QrViewModel.kt @@ -66,7 +66,7 @@ internal class QrViewModel( coroutine.call(request, onSuccess = { response -> - qrImageResult.value = response.data + qrImageResult.value = response.data!! changeScreenState(LoadedState) }) } @@ -99,7 +99,7 @@ internal class QrViewModel( onSuccess = { when (type) { DataTypeQr.IMAGE -> { - qrImageResult.value = it.data + qrImageResult.value = it.data!! coroutine.runWithDelay(15000) { getState(paymentId) } @@ -118,7 +118,7 @@ internal class QrViewModel( coroutine.call(request, onSuccess = { response -> if (response.status == ResponseStatus.CONFIRMED || response.status == ResponseStatus.AUTHORIZED) { - paymentResult.value = response.paymentId + paymentResult.value = response.paymentId!! } else { coroutine.runWithDelay(5000) { getState(paymentId) diff --git a/ui/src/main/res/drawable/acq_button_flat_bg.xml b/ui/src/main/res/drawable/acq_button_flat_bg.xml index 4a7587a7..d1966ce0 100644 --- a/ui/src/main/res/drawable/acq_button_flat_bg.xml +++ b/ui/src/main/res/drawable/acq_button_flat_bg.xml @@ -33,7 +33,8 @@ - + + diff --git a/ui/src/main/res/layout/acq_fragment_payment_failure_insufficient_funds.xml b/ui/src/main/res/layout/acq_payment_status_form.xml similarity index 58% rename from ui/src/main/res/layout/acq_fragment_payment_failure_insufficient_funds.xml rename to ui/src/main/res/layout/acq_payment_status_form.xml index c54b5e2f..6dbca2b6 100644 --- a/ui/src/main/res/layout/acq_fragment_payment_failure_insufficient_funds.xml +++ b/ui/src/main/res/layout/acq_payment_status_form.xml @@ -1,39 +1,54 @@ + android:orientation="vertical" + android:paddingBottom="24dp"> + + + android:textStyle="bold" + tools:text="Не получилось оплатить —\nнедостаточно денег на счету" /> + android:textSize="16sp" + tools:text="Пополните его или выберите другой" /> - \ No newline at end of file diff --git a/ui/src/main/res/values-ru/strings.xml b/ui/src/main/res/values-ru/strings.xml index a9689e5a..c3a42eae 100644 --- a/ui/src/main/res/values-ru/strings.xml +++ b/ui/src/main/res/values-ru/strings.xml @@ -80,8 +80,17 @@ Обрабатываем платеж Это займет некоторое время Оплачено - Не получилось оплатить + Ошибка при оплате + Попробуйте другой способ оплаты + Выбрать другой способ оплаты Понятно Воспользуйтесь другим\nспособом оплаты + Ждем оплату в приложении банка + Закрыть + + Время оплаты истекло + Попробуйте оплатить снова или выберите другой способ оплаты + Оплатить еще раз + Другой способ оплаты \ No newline at end of file diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 9ab22402..6d1a9240 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -29,9 +29,19 @@ Processing the payment it will take some time Paid + + Payment time has expired + Try to pay again or choose another payment method + Pay again + Other payment method + Payment error + Try to pay again or choose another payment method + Pay again OK Use a different payment method + Waiting for payment in the bank application + Close %1$s • %2$s Your cards diff --git a/ui/src/main/res/values/styles.xml b/ui/src/main/res/values/styles.xml index 0e936c7a..8552bd95 100644 --- a/ui/src/main/res/values/styles.xml +++ b/ui/src/main/res/values/styles.xml @@ -283,4 +283,11 @@ 32dp + + + diff --git a/ui/src/test/java/common/AssertExt.kt b/ui/src/test/java/common/AssertExt.kt index d6dc8193..e5ab1eac 100644 --- a/ui/src/test/java/common/AssertExt.kt +++ b/ui/src/test/java/common/AssertExt.kt @@ -9,6 +9,6 @@ fun assertByClassName(expected: Any?, actual: Any?) { Assert.assertEquals(expected?.javaClass?.simpleName, actual?.javaClass?.simpleName) } -fun assertByClassName(expected: Class<*>, actual: Class<*>) { - Assert.assertEquals(expected?.javaClass?.simpleName, actual?.javaClass?.simpleName) +inline fun assertViaClassName(expected: Class, actual: T) { + Assert.assertEquals(expected.simpleName, actual::class.java.simpleName) } \ No newline at end of file diff --git a/ui/src/test/java/sbp/SbpTestEnvironment.kt b/ui/src/test/java/sbp/SbpTestEnvironment.kt new file mode 100644 index 00000000..1d539eb0 --- /dev/null +++ b/ui/src/test/java/sbp/SbpTestEnvironment.kt @@ -0,0 +1,102 @@ +package sbp + +import kotlinx.coroutines.* +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import ru.tinkoff.acquiring.sdk.AcquiringSdk +import ru.tinkoff.acquiring.sdk.payment.SbpPaymentProcess +import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.SbpPaymentViewModel +import ru.tinkoff.acquiring.sdk.redesign.sbp.util.NspkBankAppsProvider +import ru.tinkoff.acquiring.sdk.redesign.sbp.util.NspkInstalledAppsChecker +import ru.tinkoff.acquiring.sdk.requests.GetQrRequest +import ru.tinkoff.acquiring.sdk.requests.GetStateRequest +import ru.tinkoff.acquiring.sdk.requests.InitRequest +import ru.tinkoff.acquiring.sdk.requests.performSuspendRequest +import ru.tinkoff.acquiring.sdk.responses.GetQrResponse +import ru.tinkoff.acquiring.sdk.responses.InitResponse +import ru.tinkoff.acquiring.sdk.utils.ConnectionChecker +import ru.tinkoff.acquiring.sdk.utils.CoroutineManager + +val nspkApps = setOf("ru.nspk.sbpay") + + +/** + * Created by i.golovachev + */ +internal class SbpTestEnvironment( + val connectionChecker: ConnectionChecker = mock { + on { isOnline() } doReturn true + }, + val bankAppsProvider: NspkInstalledAppsChecker = NspkInstalledAppsChecker { _, _ -> nspkApps.toList() }, + val nspkBankAppsProvider: NspkBankAppsProvider = NspkBankAppsProvider { nspkApps }, + + // env + val dispatcher: CoroutineDispatcher = Dispatchers.Unconfined, + val processJob: Job = SupervisorJob(), + val paymentId: Long = 1, + val deeplink: String = "https://qr.nspk.ru/test_link", + + + // requests + val initRequest: InitRequest = mock(), + val getQrRequest: GetQrRequest = mock(), + val getState: GetStateRequest = mock() +) { + val sdk: AcquiringSdk = mock { + on { init(any()) } doReturn initRequest + on { getQr(any()) } doReturn getQrRequest + on { getState(any()) } doReturn getState + } + + val sbpPaymentProgress = SbpPaymentProcess(sdk, bankAppsProvider, nspkBankAppsProvider, CoroutineScope( dispatcher + processJob)) + val viewModel: SbpPaymentViewModel = SbpPaymentViewModel( + connectionChecker, + sbpPaymentProgress, + CoroutineManager(dispatcher, dispatcher) + ) + + suspend fun setInitResult(throwable: Throwable) { + val result = Result.failure(throwable) + + whenever(initRequest.performSuspendRequest()) + .doReturn(result) + } + + suspend fun setInitResult(definePaymentId: Long? = null) { + val response = InitResponse(paymentId = definePaymentId) + val result = Result.success(response) + + whenever(initRequest.performSuspendRequest()) + .doReturn(result) + } + + suspend fun setGetQrResult(throwable: Throwable) { + val result = Result.failure(throwable) + + whenever(getQrRequest.performSuspendRequest()) + .doReturn(result) + } + + suspend fun setGetQrResult(deeplink: String) { + val response = GetQrResponse(data = deeplink) + val result = Result.success(response) + + whenever(getQrRequest.performSuspendRequest()) + .doReturn(result) + } +} + +internal fun SbpTestEnvironment.runWithEnv( + given: suspend SbpTestEnvironment.() -> Unit, + `when`: suspend SbpTestEnvironment.() -> Unit, + then: suspend SbpTestEnvironment.() -> Unit +) { + runBlocking { + launch { given.invoke(this@runWithEnv) }.join() + launch { `when`.invoke(this@runWithEnv) }.join() + launch { then.invoke(this@runWithEnv) }.join() + } +} + diff --git a/ui/src/test/java/sbp/ShowBanksApps.kt b/ui/src/test/java/sbp/ShowBanksApps.kt new file mode 100644 index 00000000..f6ece9c1 --- /dev/null +++ b/ui/src/test/java/sbp/ShowBanksApps.kt @@ -0,0 +1,28 @@ +package sbp + +import common.assertViaClassName +import org.junit.Test +import org.mockito.kotlin.mock +import ru.tinkoff.acquiring.sdk.payment.SbpPaymentState + +/** + * Created by i.golovachev + */ +class ShowBanksApps { + + @Test + fun `check progress WHEN start screen`() { + SbpTestEnvironment().runWithEnv( + given = { + setInitResult(definePaymentId = paymentId) + setGetQrResult(deeplink = deeplink) + }, + `when` = { + sbpPaymentProgress.start(mock()) + }, + then = { + assertViaClassName(SbpPaymentState.NeedChooseOnUi::class.java, sbpPaymentProgress.state.value) + } + ) + } +} \ No newline at end of file From e9d2b92f442204e2e9b309ec9f4165da9ef87bac Mon Sep 17 00:00:00 2001 From: jqwout Date: Tue, 24 Jan 2023 16:26:32 +0300 Subject: [PATCH 032/126] =?UTF-8?q?MC-7997=20=D0=BE=D0=BF=D0=BB=D0=B0?= =?UTF-8?q?=D1=82=D0=B0=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20=D0=A1=D0=91?= =?UTF-8?q?=D0=9F=20=D0=B8=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=D1=8B=20?= =?UTF-8?q?=D0=B2=20=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AcquiringSdkTimeoutException.kt | 24 ++ .../sdk/models/enums/ResponseStatus.kt | 1 + .../acquiring/sdk/models/paysources/SbpPay.kt | 11 + .../acquiring/sdk/network/AcquiringApi.kt | 2 +- gradle.properties | 2 +- .../acquiring/sample/ui/MainActivity.kt | 18 -- .../acquiring/sample/ui/PayableActivity.kt | 40 +++- .../sample/utils/CombInitDelegate.kt | 29 +++ .../acquiring/sample/utils/SessionParams.kt | 2 + .../sample/utils/SettingsSdkManager.kt | 3 + .../sample/utils/TerminalsManager.kt | 9 +- sample/src/main/res/values-ru/strings.xml | 1 + .../src/main/res/values/preferences_keys.xml | 1 + sample/src/main/res/values/strings.xml | 1 + sample/src/main/res/xml/settings.xml | 6 + ui/build.gradle | 1 + ui/src/main/AndroidManifest.xml | 2 +- .../tinkoff/acquiring/sdk/TinkoffAcquiring.kt | 64 ++++- .../acquiring/sdk/models/NspkRequest.kt | 15 +- .../acquiring/sdk/payment/PaymentState.kt | 7 +- .../sdk/payment/SbpPaymentProcess.kt | 212 +++++++++++++++++ .../sdk/payment/YandexPaymentProcess.kt | 2 +- ...tFailureInsufficientFundsDialogFragment.kt | 48 ---- .../redesign/dialog/PaymentStatusFormExt.kt | 47 ++++ .../sdk/redesign/dialog/PaymentStatusSheet.kt | 133 +++++++++++ .../sdk/redesign/sbp/ui/BankListViewModel.kt | 57 ----- .../redesign/sbp/ui/SbpNoBanksStubActivity.kt | 27 ++- ...kListActivity.kt => SbpPaymentActivity.kt} | 218 ++++++++++-------- .../redesign/sbp/ui/SbpPaymentViewModel.kt | 68 ++++++ .../redesign/sbp/util/NspkBankAppsProvider.kt | 5 + .../sbp/util/NspkInstalledAppsChecker.kt | 10 + .../sdk/redesign/sbp/util/SbpHelper.kt | 5 +- .../sdk/redesign/sbp/util/SbpStateMapper.kt | 70 ++++++ .../acquiring/sdk/utils/CoroutineManager.kt | 64 +++-- .../ru/tinkoff/acquiring/sdk/utils/FlowExt.kt | 11 + .../tinkoff/acquiring/sdk/utils/NspkClient.kt | 58 ++--- .../acquiring/sdk/viewmodel/QrViewModel.kt | 6 +- .../main/res/drawable/acq_button_flat_bg.xml | 3 +- ..._funds.xml => acq_payment_status_form.xml} | 30 ++- ui/src/main/res/values-ru/strings.xml | 9 + ui/src/main/res/values/strings.xml | 10 + ui/src/main/res/values/styles.xml | 7 + ui/src/test/java/common/AssertExt.kt | 4 +- ui/src/test/java/sbp/SbpTestEnvironment.kt | 102 ++++++++ ui/src/test/java/sbp/ShowBanksApps.kt | 28 +++ 45 files changed, 1167 insertions(+), 306 deletions(-) create mode 100644 core/src/main/java/ru/tinkoff/acquiring/sdk/exceptions/AcquiringSdkTimeoutException.kt create mode 100644 core/src/main/java/ru/tinkoff/acquiring/sdk/models/paysources/SbpPay.kt create mode 100644 sample/src/main/java/ru/tinkoff/acquiring/sample/utils/CombInitDelegate.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/SbpPaymentProcess.kt delete mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentFailureInsufficientFundsDialogFragment.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusFormExt.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusSheet.kt delete mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/BankListViewModel.kt rename ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/{BankListActivity.kt => SbpPaymentActivity.kt} (50%) create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentViewModel.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/NspkBankAppsProvider.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/NspkInstalledAppsChecker.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpStateMapper.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/FlowExt.kt rename ui/src/main/res/layout/{acq_fragment_payment_failure_insufficient_funds.xml => acq_payment_status_form.xml} (58%) create mode 100644 ui/src/test/java/sbp/SbpTestEnvironment.kt create mode 100644 ui/src/test/java/sbp/ShowBanksApps.kt diff --git a/core/src/main/java/ru/tinkoff/acquiring/sdk/exceptions/AcquiringSdkTimeoutException.kt b/core/src/main/java/ru/tinkoff/acquiring/sdk/exceptions/AcquiringSdkTimeoutException.kt new file mode 100644 index 00000000..16bfae35 --- /dev/null +++ b/core/src/main/java/ru/tinkoff/acquiring/sdk/exceptions/AcquiringSdkTimeoutException.kt @@ -0,0 +1,24 @@ +/* + * Copyright © 2020 Tinkoff Bank + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ru.tinkoff.acquiring.sdk.exceptions + +/** + * Исключение, выбрасываемое в случае, когда ожидание платежа истекло + * + * @author i.golovachev + */ +class AcquiringSdkTimeoutException(throwable: Throwable) : RuntimeException(throwable.message, throwable) \ No newline at end of file diff --git a/core/src/main/java/ru/tinkoff/acquiring/sdk/models/enums/ResponseStatus.kt b/core/src/main/java/ru/tinkoff/acquiring/sdk/models/enums/ResponseStatus.kt index d3e4bdc2..a062a7c7 100644 --- a/core/src/main/java/ru/tinkoff/acquiring/sdk/models/enums/ResponseStatus.kt +++ b/core/src/main/java/ru/tinkoff/acquiring/sdk/models/enums/ResponseStatus.kt @@ -43,6 +43,7 @@ enum class ResponseStatus { LOOP_CHECKING, COMPLETED, AUTH_FAIL, + DEADLINE_EXPIRED, FORM_SHOWED; override fun toString(): String { diff --git a/core/src/main/java/ru/tinkoff/acquiring/sdk/models/paysources/SbpPay.kt b/core/src/main/java/ru/tinkoff/acquiring/sdk/models/paysources/SbpPay.kt new file mode 100644 index 00000000..fbfac499 --- /dev/null +++ b/core/src/main/java/ru/tinkoff/acquiring/sdk/models/paysources/SbpPay.kt @@ -0,0 +1,11 @@ +package ru.tinkoff.acquiring.sdk.models.paysources + +import ru.tinkoff.acquiring.sdk.models.PaymentSource + +/** + * Тип оплаты с помощью SBP Pay + * + * + * Created by i.golovachev + */ +object SbpPay : PaymentSource \ No newline at end of file diff --git a/core/src/main/java/ru/tinkoff/acquiring/sdk/network/AcquiringApi.kt b/core/src/main/java/ru/tinkoff/acquiring/sdk/network/AcquiringApi.kt index c060a0ad..2778147a 100644 --- a/core/src/main/java/ru/tinkoff/acquiring/sdk/network/AcquiringApi.kt +++ b/core/src/main/java/ru/tinkoff/acquiring/sdk/network/AcquiringApi.kt @@ -75,7 +75,7 @@ object AcquiringApi { internal const val API_REQUEST_METHOD_POST = "POST" internal const val API_REQUEST_METHOD_GET = "GET" - internal const val JSON = "application/json" + const val JSON = "application/json" internal const val FORM_URL_ENCODED = "application/x-www-form-urlencoded" internal const val TIMEOUT = 40000L diff --git a/gradle.properties b/gradle.properties index 712eb365..7fb64772 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -VERSION_NAME=2.12.0 +VERSION_NAME=3.0.0 VERSION_CODE=19 GROUP=ru.tinkoff.acquiring diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt index 9b951dc5..a0bee731 100644 --- a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt @@ -44,9 +44,6 @@ import ru.tinkoff.acquiring.sdk.localization.AsdkSource import ru.tinkoff.acquiring.sdk.localization.Language import ru.tinkoff.acquiring.sdk.models.options.FeaturesOptions import ru.tinkoff.acquiring.sdk.models.result.CardResult -import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.BankList -import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.SbpNoBanksStubActivity -import ru.tinkoff.acquiring.sdk.redesign.sbp.util.SbpHelper import ru.tinkoff.acquiring.sdk.threeds.ThreeDsHelper /** @@ -76,17 +73,6 @@ class MainActivity : AppCompatActivity(), BooksListAdapter.BookDetailsClickListe } } - private val chooseBank = registerForActivityResult(BankList.Contract) { result -> - when (result) { - is BankList.Success -> { - SbpHelper.openSbpDeeplink(result.deeplink, result.packageName, this) - } - is BankList.Error -> toast(result.error.message ?: getString(R.string.error_title)) - is BankList.NoBanks -> SbpNoBanksStubActivity.show(this) - is BankList.Canceled -> toast("SBP canceled") - } - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -149,10 +135,6 @@ class MainActivity : AppCompatActivity(), BooksListAdapter.BookDetailsClickListe startActivity(Intent(this, TerminalsActivity::class.java)) true } - R.id.sbp_bank_list -> { - chooseBank.launch("https://qr.nspk.ru/test_link") - true - } R.id.menu_action_about -> { AboutActivity.start(this) true diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt index bb9094d5..300284b0 100644 --- a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt @@ -27,8 +27,13 @@ import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isVisible import androidx.fragment.app.commit +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import ru.tinkoff.acquiring.sample.R import ru.tinkoff.acquiring.sample.SampleApplication +import ru.tinkoff.acquiring.sample.ui.MainActivity.Companion.toast +import ru.tinkoff.acquiring.sample.utils.CombInitDelegate import ru.tinkoff.acquiring.sample.utils.SessionParams import ru.tinkoff.acquiring.sample.utils.SettingsSdkManager import ru.tinkoff.acquiring.sample.utils.TerminalsManager @@ -42,6 +47,7 @@ import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions import ru.tinkoff.acquiring.sdk.payment.PaymentListener import ru.tinkoff.acquiring.sdk.payment.PaymentListenerAdapter import ru.tinkoff.acquiring.sdk.payment.PaymentState +import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.SbpNoBanksStubActivity import ru.tinkoff.acquiring.sdk.utils.GooglePayHelper import ru.tinkoff.acquiring.sdk.utils.Money import ru.tinkoff.acquiring.yandexpay.YandexButtonFragment @@ -73,6 +79,16 @@ open class PayableActivity : AppCompatActivity() { private val orderId: String get() = abs(Random().nextInt()).toString() private var acqFragment: YandexButtonFragment? = null + private val combInitDelegate: CombInitDelegate = CombInitDelegate(tinkoffAcquiring.sdk, Dispatchers.IO) + private val spbPayment = registerForActivityResult(TinkoffAcquiring.SbpScreen.Contract) { result -> + when (result) { + is TinkoffAcquiring.SbpScreen.Success -> { + toast("SBP Success") + } + is TinkoffAcquiring.SbpScreen.Error -> toast(result.error.message ?: getString(R.string.error_title)) + is TinkoffAcquiring.SbpScreen.Canceled -> toast("SBP canceled") + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -145,7 +161,29 @@ open class PayableActivity : AppCompatActivity() { } protected fun startSbpPayment() { - tinkoffAcquiring.payWithSbp(this, createPaymentOptions(), PAYMENT_REQUEST_CODE) + lifecycleScope.launch { + val opt = createPaymentOptions() + opt.setTerminalParams( + TerminalsManager.selectedTerminal.terminalKey, + TerminalsManager.selectedTerminal.publicKey + ) + if (settings.isEnableCombiInit) { + showProgressDialog() + runCatching { combInitDelegate.sendInit(opt).paymentId!! } + .onFailure { + hideProgressDialog() + showErrorDialog() + } + .onSuccess { + hideProgressDialog() + tinkoffAcquiring.initSbpPaymentSession() + spbPayment.launch(TinkoffAcquiring.SbpScreen.StartData(it,opt)) + } + } else { + tinkoffAcquiring.initSbpPaymentSession() + spbPayment.launch(TinkoffAcquiring.SbpScreen.StartData(opt)) + } + } } protected fun setupTinkoffPay() { diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/CombInitDelegate.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/CombInitDelegate.kt new file mode 100644 index 00000000..1634d671 --- /dev/null +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/CombInitDelegate.kt @@ -0,0 +1,29 @@ +package ru.tinkoff.acquiring.sample.utils + +import kotlinx.coroutines.* +import ru.tinkoff.acquiring.sdk.AcquiringSdk +import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions +import ru.tinkoff.acquiring.sdk.payment.PaymentProcess.Companion.configure +import ru.tinkoff.acquiring.sdk.requests.performSuspendRequest +import ru.tinkoff.acquiring.sdk.responses.InitResponse +import kotlin.coroutines.CoroutineContext + + +/** + * Created by i.golovachev + * + * Имитирует запрос к строннему бекенду мерчанта, в рамках совершения комби-инит платежа + */ +class CombInitDelegate(private val sdk: AcquiringSdk, private val context: CoroutineContext) { + + private val combiInitAdditional = + mapOf("merchant init response field" to "merchant init response value") + + suspend fun sendInit(paymentOptions: PaymentOptions): InitResponse { + paymentOptions.order.additionalData = + paymentOptions.order.additionalData?.plus(combiInitAdditional) ?: combiInitAdditional + return withContext(context) { + sdk.init { configure(paymentOptions) }.performSuspendRequest().getOrThrow() + } + } +} \ No newline at end of file diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/SessionParams.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/SessionParams.kt index 8aa1a70a..a9462338 100644 --- a/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/SessionParams.kt +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/SessionParams.kt @@ -63,6 +63,8 @@ data class SessionParams( SessionParams("1578942570730", PASSWORD, PUBLIC_KEY, DEFAULT_CUSTOMER_KEY, DEFAULT_CUSTOMER_EMAIL, "SBP 3"), SessionParams("1661351612593", "45tnvz0kkyyz82mw", PUBLIC_KEY, DEFAULT_CUSTOMER_KEY, DEFAULT_CUSTOMER_EMAIL, "With token"), SessionParams("1661161705205", null, PUBLIC_KEY, DEFAULT_CUSTOMER_KEY, DEFAULT_CUSTOMER_EMAIL, "Without token"), + SessionParams("1584440932619", "dniplpm7ct3tg9e3", PUBLIC_KEY, DEFAULT_CUSTOMER_KEY, DEFAULT_CUSTOMER_EMAIL, "Sbp pay with token"), + SessionParams("1674123391307", "rpcmn7osqle5sj2r", PUBLIC_KEY, DEFAULT_CUSTOMER_KEY, DEFAULT_CUSTOMER_EMAIL, "new merchant") ) } } diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/SettingsSdkManager.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/SettingsSdkManager.kt index 31b2980e..5257ffd1 100644 --- a/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/SettingsSdkManager.kt +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/SettingsSdkManager.kt @@ -56,6 +56,9 @@ class SettingsSdkManager(private val context: Context) { val yandexPayEnabled: Boolean get() = preferences.getBoolean(context.getString(R.string.acq_sp_yandex_pay), true) + val isEnableCombiInit: Boolean + get() = preferences.getBoolean(context.getString(R.string.acq_sp_combi_init), true) + val checkType: String get() { val defaultCheckType = context.getString(R.string.acq_sp_check_type_no) diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/TerminalsManager.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/TerminalsManager.kt index 8100e513..eb183741 100644 --- a/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/TerminalsManager.kt +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/TerminalsManager.kt @@ -25,7 +25,7 @@ object TerminalsManager { value.find { it.terminalKey == SessionParams.TEST_SDK.terminalKey } != null -> value else -> value.toMutableList().apply { add(0, SessionParams.TEST_SDK) } } - prefs.edit().putString(TERMINALS_KEY, gson.toJson(_terminals)).apply() + prefs.edit().putString(TERMINALS_KEY, gson.toJson(_terminals)).commit() if (terminals.find { it.terminalKey == _selectedTerminalKey } == null) { selectedTerminalKey = terminals.first().terminalKey @@ -37,7 +37,7 @@ object TerminalsManager { get() = _selectedTerminalKey!! set(value) { _selectedTerminalKey = value - prefs.edit().putString(SELECTED_TERMINAL_KEY, value).apply() + prefs.edit().putString(SELECTED_TERMINAL_KEY, value).commit() SampleApplication.initSdk(context, selectedTerminal) } @@ -65,7 +65,10 @@ object TerminalsManager { selectedTerminalKey = terminals.first().terminalKey } - fun findTerminal(terminalKey: String) = terminals.find { it.terminalKey == terminalKey } + fun findTerminal(terminalKey: String): SessionParams? { + val terminal = terminals.find { it.terminalKey == terminalKey } + return terminal + } fun requireTerminal(terminalKey: String) = findTerminal(terminalKey)!! } \ No newline at end of file diff --git a/sample/src/main/res/values-ru/strings.xml b/sample/src/main/res/values-ru/strings.xml index e5759b56..f3545813 100644 --- a/sample/src/main/res/values-ru/strings.xml +++ b/sample/src/main/res/values-ru/strings.xml @@ -75,6 +75,7 @@ Темная тема CheckType Модуль камеры + Combi - Init Невозможно завершить привязку карты Привязка карты была отменена diff --git a/sample/src/main/res/values/preferences_keys.xml b/sample/src/main/res/values/preferences_keys.xml index 7e2ec691..dec68386 100644 --- a/sample/src/main/res/values/preferences_keys.xml +++ b/sample/src/main/res/values/preferences_keys.xml @@ -22,6 +22,7 @@ acq_sp_recurrent_payment acq_sp_android_pay acq_sp_yandex_pay + acq_sp_combi_init acq_sp_fps acq_sp_tinkoff_pay acq_sp_handle_cards_error diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml index eb5af723..4ecebaa8 100644 --- a/sample/src/main/res/values/strings.xml +++ b/sample/src/main/res/values/strings.xml @@ -74,6 +74,7 @@ Dark Mode CheckType Camera module + Combi - Init Attachment failed. Try later. Attachment canceled diff --git a/sample/src/main/res/xml/settings.xml b/sample/src/main/res/xml/settings.xml index bc6f177a..6254745f 100644 --- a/sample/src/main/res/xml/settings.xml +++ b/sample/src/main/res/xml/settings.xml @@ -63,6 +63,12 @@ android:title="@string/settings_title_yandex_pay" app:iconSpaceReserved="false" /> + + diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt index f320f936..b9324f76 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/TinkoffAcquiring.kt @@ -20,9 +20,12 @@ import android.app.Activity import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.os.Parcelable import androidx.activity.result.contract.ActivityResultContract +import androidx.annotation.MainThread import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment +import kotlinx.android.parcel.Parcelize import kotlinx.coroutines.* import ru.tinkoff.acquiring.sdk.localization.LocalizationSource import ru.tinkoff.acquiring.sdk.models.* @@ -32,10 +35,11 @@ import ru.tinkoff.acquiring.sdk.models.paysources.AttachedCard import ru.tinkoff.acquiring.sdk.models.paysources.CardData import ru.tinkoff.acquiring.sdk.models.paysources.GooglePay import ru.tinkoff.acquiring.sdk.payment.PaymentProcess +import ru.tinkoff.acquiring.sdk.payment.SbpPaymentProcess import ru.tinkoff.acquiring.sdk.requests.performSuspendRequest -import ru.tinkoff.acquiring.sdk.responses.GetTerminalPayMethodsResponse import ru.tinkoff.acquiring.sdk.responses.TerminalInfo import ru.tinkoff.acquiring.sdk.redesign.cards.list.ui.CardsListActivity +import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.SbpPaymentActivity import ru.tinkoff.acquiring.sdk.responses.TinkoffPayStatusResponse import ru.tinkoff.acquiring.sdk.ui.activities.* import ru.tinkoff.acquiring.sdk.ui.activities.AttachCardActivity @@ -177,10 +181,24 @@ class TinkoffAcquiring( * @param options настройки платежной сессии * @param requestCode код для получения результата, по завершению работы SDK */ + @Deprecated("registerForActivityResult(SbpScreen.Contract) { result -> }.launch(options)", + ReplaceWith("registerForActivityResult(SbpScreen.Contract) { cardId ->\n" + + " // handle result\n" + + "}.launch(attachCardOptions {\n" + + " //setup options\n" + + "})")) fun payWithSbp(activity: Activity, options: PaymentOptions, requestCode: Int) { openPaymentScreen(activity, options, requestCode, FpsState) } + /** + * Создает платежную сессию в рамках оплаты по Системе быстрых платежей + */ + @MainThread + fun initSbpPaymentSession(){ + SbpPaymentProcess.init(sdk, applicationContext.packageManager) + } + /** * Запуск SDK для оплаты через Систему быстрых платежей * @@ -188,6 +206,12 @@ class TinkoffAcquiring( * @param options настройки платежной сессии * @param requestCode код для получения результата, по завершению работы SDK */ + @Deprecated("registerForActivityResult(SbpScreen.Contract) { result -> }.launch(options)", + ReplaceWith("registerForActivityResult(SbpScreen.Contract) { cardId ->\n" + + " // handle result\n" + + "}.launch(attachCardOptions {\n" + + " //setup options\n" + + "})")) fun payWithSbp(fragment: Fragment, options: PaymentOptions, requestCode: Int) { openPaymentScreen(fragment, options, requestCode, FpsState) } @@ -543,6 +567,44 @@ class TinkoffAcquiring( } } + object SbpScreen { + + sealed class Result + class Success(val payment: Long) : Result() + class Canceled : Result() + class Error(val error: Throwable) : Result() + class NoBanks() : Result() + + @Parcelize + class StartData private constructor( + val paymentOptions: PaymentOptions, val paymentId: Long? + ) : Parcelable { + + // для обычного платежа + constructor(paymentOptions: PaymentOptions) : this(paymentOptions, null) + + // если вызов init был на стороне вашего сервера + constructor(paymentId: Long, paymentOptions: PaymentOptions) : this(paymentOptions, paymentId) + } + + object Contract: ActivityResultContract() { + + override fun createIntent(context: Context, data: StartData): Intent = + Intent(context, SbpPaymentActivity::class.java).apply { + putExtra(SbpPaymentActivity.EXTRA_PAYMENT_DATA, data) + } + + override fun parseResult(resultCode: Int, intent: Intent?): Result = when (resultCode) { + AppCompatActivity.RESULT_OK -> Success( + intent!!.getLongExtra(SbpPaymentActivity.EXTRA_PAYMENT_ID, 0), + ) + TinkoffAcquiring.RESULT_ERROR -> Error(intent!!.getSerializableExtra(TinkoffAcquiring.EXTRA_ERROR)!! as Throwable) + SbpPaymentActivity.SBP_BANK_RESULT_CODE_NO_BANKS -> NoBanks() + else -> Canceled() + } + } + } + companion object { const val RESULT_ERROR = 500 diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/models/NspkRequest.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/models/NspkRequest.kt index 5005f89a..0b321a82 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/models/NspkRequest.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/models/NspkRequest.kt @@ -16,13 +16,17 @@ package ru.tinkoff.acquiring.sdk.models +import kotlinx.coroutines.CompletableDeferred import ru.tinkoff.acquiring.sdk.utils.NspkClient import ru.tinkoff.acquiring.sdk.utils.Request +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine /** * @author Mariya Chernyadieva */ -internal class NspkRequest: Request { +internal class NspkRequest : Request { @Volatile private var disposed = false @@ -39,4 +43,13 @@ internal class NspkRequest: Request { val client = NspkClient() client.call(this, onSuccess, onFailure) } + + suspend fun execute(): NspkResponse { + return suspendCoroutine { continuation -> + execute( + onSuccess = { continuation.resume(it) }, + onFailure = { continuation.resumeWithException(it) } + ) + } + } } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentState.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentState.kt index f21ca6f2..63d20e27 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentState.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/PaymentState.kt @@ -16,7 +16,6 @@ package ru.tinkoff.acquiring.sdk.payment -import ru.tinkoff.acquiring.sdk.models.AsdkState import ru.tinkoff.acquiring.sdk.models.ThreeDsState /** @@ -45,12 +44,14 @@ enum class PaymentState { sealed interface YandexPaymentState { object Created : YandexPaymentState object Started : YandexPaymentState - class Registred(val paymentId: Long): YandexPaymentState + class Registred(val paymentId: Long) : YandexPaymentState object ThreeDsRejected : YandexPaymentState class ThreeDsUiNeeded(val asdkState: ThreeDsState) : YandexPaymentState class Error(val paymentId: Long?, val throwable: Throwable) : YandexPaymentState - class Success(val paymentId: Long,val cardId: String?, val rebillId: String?) : YandexPaymentState + class Success(val paymentId: Long, val cardId: String?, val rebillId: String?) : + YandexPaymentState + object Stopped : YandexPaymentState } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/SbpPaymentProcess.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/SbpPaymentProcess.kt new file mode 100644 index 00000000..edf749fc --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/SbpPaymentProcess.kt @@ -0,0 +1,212 @@ +package ru.tinkoff.acquiring.sdk.payment + +import android.content.pm.PackageManager +import androidx.annotation.MainThread +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import ru.tinkoff.acquiring.sdk.AcquiringSdk +import ru.tinkoff.acquiring.sdk.exceptions.AcquiringSdkException +import ru.tinkoff.acquiring.sdk.exceptions.AcquiringSdkTimeoutException +import ru.tinkoff.acquiring.sdk.models.NspkRequest +import ru.tinkoff.acquiring.sdk.models.enums.DataTypeQr +import ru.tinkoff.acquiring.sdk.models.enums.ResponseStatus +import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions +import ru.tinkoff.acquiring.sdk.payment.PaymentProcess.Companion.configure +import ru.tinkoff.acquiring.sdk.redesign.sbp.util.NspkBankAppsProvider +import ru.tinkoff.acquiring.sdk.redesign.sbp.util.NspkInstalledAppsChecker +import ru.tinkoff.acquiring.sdk.redesign.sbp.util.SbpHelper +import ru.tinkoff.acquiring.sdk.requests.performSuspendRequest +import ru.tinkoff.acquiring.sdk.responses.GetStateResponse + +/** + * Created by i.golovachev + */ +class SbpPaymentProcess internal constructor( + private val sdk: AcquiringSdk, + private val bankAppsProvider: NspkInstalledAppsChecker, + private val nspkBankProvider: NspkBankAppsProvider, + private val scope: CoroutineScope +) { + internal constructor( + sdk: AcquiringSdk, + bankAppsProvider: NspkInstalledAppsChecker, + nspkBankAppsProvider: NspkBankAppsProvider, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO + ) : this(sdk, bankAppsProvider, nspkBankAppsProvider, CoroutineScope(ioDispatcher)) + + val state = MutableStateFlow(SbpPaymentState.Created) + private var looperJob: Job = Job() + + fun start(paymentOptions: PaymentOptions, paymentId: Long? = null) { + scope.launch { + runOrCatch { + val nspkApps = + nspkBankProvider.getNspkApps() + val id = paymentId ?: sendInit(paymentOptions) + state.value = SbpPaymentState.Started(id) + val deeplink = sendGetQr(id) + + val installedApps = + bankAppsProvider.checkInstalledApps(nspkApps, deeplink) + state.value = + SbpPaymentState.NeedChooseOnUi(id, installedApps, deeplink) + } + } + } + + fun goingToBankApp() { + val _state = state.value + when (_state) { + is SbpPaymentState.NeedChooseOnUi -> { + state.value = SbpPaymentState.LeaveOnBankApp(_state.paymentId) + } + is SbpPaymentState.Stopped -> { + state.value = SbpPaymentState.LeaveOnBankApp(_state.paymentId!!) + } + } + } + + fun startCheckingStatus(retriesCount: Int = 10) { + // выйдем из функции если стейт уже проверяется или вызов некорректен + val _state = state.value + if (_state is SbpPaymentState.LeaveOnBankApp) { + looperJob = scope.launch { + StatusLooper(_state.paymentId, sdk, state).start(retriesCount) + } + } + } + + fun stop() { + state.value = SbpPaymentState.Stopped(state.value.paymentId) + if (looperJob.isActive) { + looperJob.cancel() + } + } + + private suspend fun runOrCatch(block: suspend () -> Unit) = try { + block() + } catch (throwable: Throwable) { + state.update { + if (throwable is CancellationException) { + SbpPaymentState.Stopped(it.paymentId) + } else { + SbpPaymentState.GetBankListFailed(it.paymentId, throwable) + } + } + } + + private suspend fun sendInit(paymentOptions: PaymentOptions): Long { + val response = sdk.init { configure(paymentOptions) }.performSuspendRequest().getOrThrow() + return response.paymentId!! + } + + private suspend fun sendGetQr(paymentId: Long) = checkNotNull( + sdk.getQr { + this.paymentId = paymentId + this.dataType = DataTypeQr.PAYLOAD + }.performSuspendRequest().getOrThrow().data, + ) { "data from NSPK are null" } + + class StatusLooper( + private val _paymentId: Long, + private val sdk: AcquiringSdk, + private val state: MutableStateFlow, + ) { + suspend fun start(retriesCount: Int) { + var tries = 0 + while (retriesCount > tries) { + val response = getStateOrNull() + val status = response?.status + when (status) { + ResponseStatus.AUTHORIZED, ResponseStatus.CONFIRMED -> { + state.value = SbpPaymentState.Success( + _paymentId, null, null + ) + return + } + ResponseStatus.REJECTED -> { + state.value = SbpPaymentState.PaymentFailed( + _paymentId, + AcquiringSdkException(IllegalStateException("PaymentState = $status")) + ) + return + } + ResponseStatus.DEADLINE_EXPIRED -> { + state.value = SbpPaymentState.PaymentFailed( + _paymentId, + AcquiringSdkTimeoutException(IllegalStateException("PaymentState = $status")) + ) + return + } + else -> { + tries += 1 + state.value = + SbpPaymentState.CheckingStatus(_paymentId, response?.status) + } + } + delay(LOOPER_DELAY_MS) + } + state.value = SbpPaymentState.PaymentFailed( + _paymentId, + AcquiringSdkTimeoutException(IllegalStateException("timeout, retries count is over")) + ) + } + + private suspend fun getStateOrNull(): GetStateResponse? { + return sdk.getState { this.paymentId = _paymentId }.performSuspendRequest() + .getOrNull() + } + } + + companion object { + private const val LOOPER_DELAY_MS = 3000L + private var instance: SbpPaymentProcess? = null + + @MainThread + internal fun init( + sdk: AcquiringSdk, + packageManager: PackageManager, + bankAppsProvider: NspkInstalledAppsChecker = NspkInstalledAppsChecker { nspkBanks, dl -> + SbpHelper.getBankApps(packageManager, dl, nspkBanks) + }, + nspkBankAppsProvider: NspkBankAppsProvider = NspkBankAppsProvider { + NspkRequest().execute().banks + } + ) { + instance?.scope?.cancel() + instance = SbpPaymentProcess(sdk, bankAppsProvider, nspkBankAppsProvider) + } + + internal fun get() = instance!! + } +} + +sealed interface SbpPaymentState { + val paymentId: Long? + + object Created : SbpPaymentState { + override val paymentId: Long? = null + } + + class Started(override val paymentId: Long) : SbpPaymentState + class NeedChooseOnUi( + override val paymentId: Long, + val bankList: List, + val deeplink: String + ) : SbpPaymentState + + class GetBankListFailed(override val paymentId: Long?, val throwable: Throwable) : + SbpPaymentState + + class LeaveOnBankApp(override val paymentId: Long) : SbpPaymentState + class CheckingStatus( + override val paymentId: Long, + val status: ResponseStatus? + ) : SbpPaymentState + + class PaymentFailed(override val paymentId: Long?, val throwable: Throwable) : SbpPaymentState + class Success(override val paymentId: Long, val cardId: String?, val rebillId: String?) : + SbpPaymentState + + class Stopped(override val paymentId: Long?) : SbpPaymentState +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/YandexPaymentProcess.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/YandexPaymentProcess.kt index 0c814eb1..3ab6bd56 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/YandexPaymentProcess.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/payment/YandexPaymentProcess.kt @@ -77,7 +77,7 @@ class YandexPaymentProcess( sendToListener(YandexPaymentState.Stopped) } - private fun sendToListener(state: YandexPaymentState?) { + private fun sendToListener(state: YandexPaymentState?) { this._state.update { state } } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentFailureInsufficientFundsDialogFragment.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentFailureInsufficientFundsDialogFragment.kt deleted file mode 100644 index 41074520..00000000 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentFailureInsufficientFundsDialogFragment.kt +++ /dev/null @@ -1,48 +0,0 @@ -package ru.tinkoff.acquiring.sdk.redesign.dialog - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import ru.tinkoff.acquiring.sdk.R -import ru.tinkoff.acquiring.sdk.ui.customview.LoaderButton -import ru.tinkoff.acquiring.sdk.utils.lazyView - -class PaymentFailureInsufficientFundsDialogFragment : BottomSheetDialogFragment() { - - private val buttonBackToPayment: LoaderButton by lazyView(R.id.acq_button_back_to_payment) - private val buttonOk: LoaderButton by lazyView(R.id.acq_button_ok) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setStyle(STYLE_NO_FRAME, theme) - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = - inflater.inflate(R.layout.acq_fragment_payment_failure_insufficient_funds, container, false) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - buttonBackToPayment.setOnClickListener { onBackToPayment() } - buttonOk.setOnClickListener { onOk() } - } - - private fun onBackToPayment() { - ((parentFragment as? OnBackToPayment) ?: (activity as? OnBackToPayment)) - ?.onPaymentFailureBackToPayment(this) - } - - private fun onOk() { - ((parentFragment as? OnOk) ?: (activity as? OnOk))?.onPaymentFailureOk(this) - } - - fun interface OnBackToPayment { - fun onPaymentFailureBackToPayment(fragment: PaymentFailureInsufficientFundsDialogFragment) - } - - fun interface OnOk { - fun onPaymentFailureOk(fragment: PaymentFailureInsufficientFundsDialogFragment) - } -} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusFormExt.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusFormExt.kt new file mode 100644 index 00000000..26d4ad09 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusFormExt.kt @@ -0,0 +1,47 @@ +package ru.tinkoff.acquiring.sdk.redesign.dialog + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import ru.tinkoff.acquiring.sdk.R + +interface OnPaymentSheetCloseListener { + fun onClose(state: PaymentSheetStatus) +} + +fun T.createPaymentSheetWrapper(): PaymentStatusSheet where T : FragmentActivity, T : OnPaymentSheetCloseListener { + return PaymentStatusSheet() +} + +fun T.createPaymentSheetWrapper(): PaymentStatusSheet where T : Fragment, T : OnPaymentSheetCloseListener { + return PaymentStatusSheet() +} + +sealed class PaymentSheetStatus( + open val title: Int?, + open val subtitle: Int? = null, + open val mainButton: Int? = null, + open val secondButton: Int? = null +) { + + object NotYet : PaymentSheetStatus(null) + + data class Progress( + override val title: Int, + override val subtitle: Int? = null, + override val secondButton: Int? = null + ) : PaymentSheetStatus(title, subtitle, null, secondButton) + + class Error( + title: Int, subtitle: Int? = null, mainButton: Int? = null, + secondButton: Int? = null, val throwable: Throwable + ) : PaymentSheetStatus(title, subtitle, mainButton, secondButton) + + class Success( + title: Int = R.string.acq_commonsheet_paid_title, + subtitle: Int? = null, + mainButton: Int? = R.string.acq_commonsheet_clear_primarybutton, + val paymentId: Long + ) : PaymentSheetStatus(title, subtitle, mainButton) + + object Hide : PaymentSheetStatus(null) +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusSheet.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusSheet.kt new file mode 100644 index 00000000..8b5d5c8e --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/dialog/PaymentStatusSheet.kt @@ -0,0 +1,133 @@ +package ru.tinkoff.acquiring.sdk.redesign.dialog + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.core.view.isVisible +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.progressindicator.CircularProgressIndicator +import ru.tinkoff.acquiring.sdk.R +import ru.tinkoff.acquiring.sdk.ui.customview.LoaderButton + +class PaymentStatusSheet internal constructor(): BottomSheetDialogFragment() { + private lateinit var icon: ImageView + private lateinit var progress: CircularProgressIndicator + private lateinit var title: TextView + private lateinit var subtitle: TextView + private lateinit var mainButton: LoaderButton + private lateinit var secondButton: LoaderButton + + @Suppress("UNCHECKED_CAST") + private val onCloseListener: OnPaymentSheetCloseListener + get() { + val listener = (parentFragment as? OnPaymentSheetCloseListener) + ?: (activity as? OnPaymentSheetCloseListener) + return checkNotNull(listener) { + "parent of fragment not implemented OnPaymentSheetCloseListener" + } + } + + var state: PaymentSheetStatus? = null + set(value) { + field = value + if (value != null && isResumed) { + showState(value) + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + setStyle(STYLE_NO_FRAME, R.style.BottomSheetDialog) + return super.onCreateDialog(savedInstanceState) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? = + inflater.inflate(R.layout.acq_payment_status_form, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + dialog?.window?.attributes?.windowAnimations = R.style.AcqBottomSheetAnim + icon = view.findViewById(R.id.acq_payment_status_form_icon) + progress = view.findViewById(R.id.acq_payment_status_formm_progress) + title = view.findViewById(R.id.acq_payment_status_form_title) + subtitle = view.findViewById(R.id.acq_payment_status_form_subtitle) + mainButton = view.findViewById(R.id.acq_payment_status_form_main_button) + secondButton = view.findViewById(R.id.acq_payment_status_form_second_button) + state?.let(::showState) + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + state?.let { + onCloseListener.onClose(it) + } + } + + private fun set( + icon: Int?, + title: Int?, + subtitle: Int?, + mainButton: Int?, + secondButton: Int?, + progress: Boolean = icon == null, + isCancelable: Boolean = progress.not() && secondButton == null + ) { + if (icon != null) + this.icon.setImageResource(icon) + + this.icon.isVisible = icon != null + + if (title != null) + this.title.setText(title) + + this.title.isVisible = title != null + + if (subtitle != null) + this.subtitle.setText(subtitle) + + this.subtitle.isVisible = title != null + + if (mainButton != null) + this.mainButton.text = getString(mainButton) + + this.mainButton.isVisible = mainButton != null + + if (secondButton != null) + this.secondButton.text = getString(secondButton) + + this.secondButton.isVisible = secondButton != null + + this.progress.isVisible = progress + + this.isCancelable = isCancelable + } + + private fun showState(state: PaymentSheetStatus) { + set( + icon = defineIcon(state), + title = state.title, + subtitle = state.subtitle, + mainButton = state.mainButton, + secondButton = state.secondButton, + ) + + this.mainButton.setOnClickListener { onCloseListener.onClose(state) } + this.secondButton.setOnClickListener { onCloseListener.onClose(state) } + } + + private fun defineIcon(state: PaymentSheetStatus) = when (state) { + is PaymentSheetStatus.Error -> R.drawable.acq_ic_cross_circle + is PaymentSheetStatus.NotYet -> null + is PaymentSheetStatus.Progress -> null + is PaymentSheetStatus.Hide -> null + is PaymentSheetStatus.Success -> R.drawable.acq_ic_check_circle_positive + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/BankListViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/BankListViewModel.kt deleted file mode 100644 index 61972597..00000000 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/BankListViewModel.kt +++ /dev/null @@ -1,57 +0,0 @@ -package ru.tinkoff.acquiring.sdk.redesign.sbp.ui - -import androidx.lifecycle.ViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import ru.tinkoff.acquiring.sdk.models.NspkRequest -import ru.tinkoff.acquiring.sdk.models.NspkResponse -import ru.tinkoff.acquiring.sdk.utils.ConnectionChecker -import ru.tinkoff.acquiring.sdk.utils.CoroutineManager - -internal class BankListViewModel( - private val bankAppsProvider: BankAppsProvider, - private val connectionChecker: ConnectionChecker, - private val manager: CoroutineManager = CoroutineManager() -) : ViewModel() { - - val stateUiFlow = MutableStateFlow(BankListState.Shimmer) - - fun loadData() { - if (connectionChecker.isOnline().not()) { - stateUiFlow.tryEmit(BankListState.NoNetwork) - return - } - stateUiFlow.tryEmit(BankListState.Shimmer) - manager.launchOnBackground { - manager.call(NspkRequest(), - onSuccess = this@BankListViewModel::handleGetBankListResponse, - onFailure = this@BankListViewModel::handleGetBankListError) - } - } - - @Suppress("UNCHECKED_CAST") - private fun handleGetBankListResponse(nspk: NspkResponse) { - try { - val banks = bankAppsProvider.getBankApps(nspk.banks) - stateUiFlow.value = if (banks.isEmpty()) { - BankListState.Empty - } else { - BankListState.Empty - } - } catch (e: Exception) { - handleGetBankListError(e) - } - } - - private fun handleGetBankListError(it: Exception) { - stateUiFlow.value = BankListState.Error(it) - } - - override fun onCleared() { - manager.cancelAll() - super.onCleared() - } - - fun interface BankAppsProvider { - fun getBankApps(nspkBanks: Set): List - } -} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpNoBanksStubActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpNoBanksStubActivity.kt index 818dcfe5..d1f4e21d 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpNoBanksStubActivity.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpNoBanksStubActivity.kt @@ -2,30 +2,53 @@ package ru.tinkoff.acquiring.sdk.redesign.sbp.ui import android.app.Activity import android.content.Intent +import android.net.Uri import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.Toolbar import ru.tinkoff.acquiring.sdk.R import ru.tinkoff.acquiring.sdk.ui.customview.LoaderButton import ru.tinkoff.acquiring.sdk.utils.lazyView -class SbpNoBanksStubActivity: AppCompatActivity() { +class SbpNoBanksStubActivity : AppCompatActivity() { + private val toolbar: Toolbar by lazyView(R.id.acq_toolbar) private val buttonOk: LoaderButton by lazyView(R.id.acq_button_ok) private val buttonDetails: LoaderButton by lazyView(R.id.acq_button_details) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.acq_activity_stub_sbp_no_banks) + initToolbar() buttonOk.setOnClickListener { finish() } buttonDetails.setOnClickListener { - // todo + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(URL_NSPK)) + startActivity(browserIntent) } } + private fun initToolbar() { + setSupportActionBar(toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.setDisplayShowHomeEnabled(true) + } + + override fun onSupportNavigateUp(): Boolean { + onBackPressed() + return true + } + + override fun onBackPressed() { + setResult(RESULT_CANCELED) + finish() + } + companion object { + private const val URL_NSPK = "https://sbp.nspk.ru/participants/" + fun show(activity: Activity) { activity.startActivity(Intent(activity, SbpNoBanksStubActivity::class.java)) } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/BankListActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentActivity.kt similarity index 50% rename from ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/BankListActivity.kt rename to ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentActivity.kt index 78f8558e..1ff6c6af 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/BankListActivity.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentActivity.kt @@ -17,7 +17,6 @@ package ru.tinkoff.acquiring.sdk.redesign.sbp.ui import android.annotation.SuppressLint -import android.content.Context import android.content.Intent import android.os.Bundle import android.view.LayoutInflater @@ -27,26 +26,34 @@ import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import android.widget.ViewFlipper -import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.core.view.children import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import ru.tinkoff.acquiring.sdk.R import ru.tinkoff.acquiring.sdk.TinkoffAcquiring import ru.tinkoff.acquiring.sdk.redesign.common.util.AcqShimmerAnimator -import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.BankListActivity.Companion.SBP_BANK_RESULT_CODE_NO_BANKS -import ru.tinkoff.acquiring.sdk.redesign.sbp.util.SbpHelper +import ru.tinkoff.acquiring.sdk.redesign.dialog.* +import ru.tinkoff.acquiring.sdk.redesign.sbp.util.SbpHelper.openSbpDeeplink import ru.tinkoff.acquiring.sdk.utils.ConnectionChecker +import ru.tinkoff.acquiring.sdk.utils.lazyUnsafe import ru.tinkoff.acquiring.sdk.utils.lazyView import ru.tinkoff.acquiring.sdk.utils.showById -internal class BankListActivity : AppCompatActivity() { +internal class SbpPaymentActivity : AppCompatActivity(), OnPaymentSheetCloseListener { - private lateinit var viewModel: BankListViewModel + private val startData: TinkoffAcquiring.SbpScreen.StartData by lazyUnsafe { + intent.getParcelableExtra(EXTRA_PAYMENT_DATA)!! + } + + private val viewModel: SbpPaymentViewModel by viewModels { + SbpPaymentViewModel.factory(ConnectionChecker(application)) + } + + private val statusFragment: PaymentStatusSheet = createPaymentSheetWrapper() private val recyclerView: RecyclerView by lazyView(R.id.acq_bank_list_content) private val cardShimmer: LinearLayout by lazyView(R.id.acq_bank_list_shimmer) @@ -57,7 +64,6 @@ internal class BankListActivity : AppCompatActivity() { private val stubButtonView: TextView by lazyView(R.id.acq_stub_retry_button) private lateinit var deeplink: String - private var banks: List? = null @SuppressLint("NotifyDataSetChanged") set(value) { @@ -68,18 +74,21 @@ internal class BankListActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.acq_activity_bank_list) - deeplink = intent.getStringExtra(EXTRA_DEEPLINK)!! - viewModel = BankListViewModel({ nspkBanks -> - SbpHelper.getBankApps(packageManager, deeplink, nspkBanks) - }, ConnectionChecker(application)) - viewModel.loadData() + if (savedInstanceState == null) { + viewModel.loadData(startData.paymentOptions, startData.paymentId) + } initToolbar() initViews() subscribeOnState() } + override fun onResume() { + super.onResume() + viewModel.startCheckingStatus() + } + private fun initToolbar() { setSupportActionBar(findViewById(R.id.acq_toolbar)) supportActionBar?.setDisplayHomeAsUpEnabled(true) @@ -93,69 +102,92 @@ internal class BankListActivity : AppCompatActivity() { } override fun onBackPressed() { + viewModel.cancelPayment() setResult(RESULT_CANCELED) finish() } + override fun onClose(status: PaymentSheetStatus) { + when (status) { + is PaymentSheetStatus.Error -> finishWithError(status.throwable) + is PaymentSheetStatus.Progress -> { + viewModel.cancelPayment() + statusFragment.dismiss() + } + is PaymentSheetStatus.Success -> finishWithResult(status.paymentId) + else -> Unit + } + } + private fun initViews() { recyclerView.adapter = Adapter() } private fun subscribeOnState() { - lifecycleScope.launch { - subscribeOnUiState() - } + lifecycleScope.launch { subscribeOnUiState() } + lifecycleScope.launch { subscribeOnSheetState() } } - private fun CoroutineScope.subscribeOnUiState() { - launch { - viewModel.stateUiFlow.collectLatest { - when (it) { - is BankListState.Content -> { - viewFlipper.showById(R.id.acq_bank_list_content) - banks = it.banks - } - is BankListState.Shimmer -> { - viewFlipper.showById(R.id.acq_bank_list_shimmer) - AcqShimmerAnimator.animateSequentially( - cardShimmer.children.toList() - ) - } - is BankListState.Error -> { - showStub( - imageResId = R.drawable.acq_ic_generic_error_stub, - titleTextRes = R.string.acq_generic_alert_label, - subTitleTextRes = R.string.acq_generic_stub_description, - buttonTextRes = R.string.acq_generic_alert_access - ) - stubButtonView.setOnClickListener { _ -> finishWithError(it.throwable) } - } - is BankListState.NoNetwork -> { - showStub( - imageResId = R.drawable.acq_ic_no_network, - titleTextRes = R.string.acq_generic_stubnet_title, - subTitleTextRes = R.string.acq_generic_stubnet_description, - buttonTextRes = R.string.acq_generic_button_stubnet - ) - stubButtonView.setOnClickListener { - viewModel.loadData() - } - } - is BankListState.Empty -> { - setResult(SBP_BANK_RESULT_CODE_NO_BANKS) - finish() + private suspend fun subscribeOnUiState() { + viewModel.stateUiFlow.collectLatest { + when (it) { + is SpbBankListState.Content -> { + viewFlipper.showById(R.id.acq_bank_list_content) + banks = it.banks + deeplink = it.deeplink + } + is SpbBankListState.Shimmer -> { + viewFlipper.showById(R.id.acq_bank_list_shimmer) + AcqShimmerAnimator.animateSequentially( + cardShimmer.children.toList() + ) + } + is SpbBankListState.Error -> { + showStub( + imageResId = R.drawable.acq_ic_generic_error_stub, + titleTextRes = R.string.acq_generic_alert_label, + subTitleTextRes = R.string.acq_generic_stub_description, + buttonTextRes = R.string.acq_generic_alert_access + ) + stubButtonView.setOnClickListener { _ -> finishWithError(it.throwable) } + } + is SpbBankListState.NoNetwork -> { + showStub( + imageResId = R.drawable.acq_ic_no_network, + titleTextRes = R.string.acq_generic_stubnet_title, + subTitleTextRes = R.string.acq_generic_stubnet_description, + buttonTextRes = R.string.acq_generic_button_stubnet + ) + stubButtonView.setOnClickListener { + viewModel.loadData(startData.paymentOptions, startData.paymentId) } } + is SpbBankListState.Empty -> { + finish() + SbpNoBanksStubActivity.show(this) + } } } } - private fun onBankSelected(packageName: String) { - setResult(RESULT_OK, Intent().apply { - putExtra(EXTRA_DEEPLINK, deeplink) - putExtra(EXTRA_PACKAGE_NAME, packageName) - }) - finish() + private suspend fun subscribeOnSheetState() { + viewModel.paymentStateFlow.collect { + statusFragment.state = it + when (it) { + is PaymentSheetStatus.Hide -> if (statusFragment.isAdded) { + statusFragment.dismiss() + } + is PaymentSheetStatus.NotYet -> Unit + else -> if (statusFragment.isAdded.not()) { + statusFragment.show(supportFragmentManager, null) + } + } + } + } + + private fun onBankSelected(packageName: String, deeplink: String) { + viewModel.onGoingToBankApp() + openSbpDeeplink(deeplink, packageName, this) } private fun showStub( @@ -177,6 +209,13 @@ internal class BankListActivity : AppCompatActivity() { stubButtonView.setText(buttonTextRes) } + private fun finishWithResult(paymentId: Long) { + val intent = Intent() + intent.putExtra(TinkoffAcquiring.EXTRA_PAYMENT_ID, paymentId) + setResult(RESULT_OK, intent) + finish() + } + private fun finishWithError(throwable: Throwable) { setErrorResult(throwable) finish() @@ -191,10 +230,14 @@ internal class BankListActivity : AppCompatActivity() { inner class Adapter : RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH = - VH(LayoutInflater.from(this@BankListActivity).inflate( - R.layout.acq_bank_list_item, parent, false)) + VH( + LayoutInflater.from(this@SbpPaymentActivity).inflate( + R.layout.acq_bank_list_item, parent, false + ) + ) - override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(banks!![position]) + override fun onBindViewHolder(holder: VH, position: Int) = + holder.bind(banks!![position], deeplink) override fun getItemCount(): Int = banks?.size ?: 0 } @@ -204,57 +247,30 @@ internal class BankListActivity : AppCompatActivity() { private val logo = view.findViewById(R.id.acq_bank_list_item_logo) private val name = view.findViewById(R.id.acq_bank_list_item_name) - fun bind(packageName: String) { + fun bind(packageName: String, deeplink: String) { logo.setImageDrawable(packageManager.getApplicationIcon(packageName)) name.text = packageManager.getApplicationLabel( - packageManager.getApplicationInfo(packageName, 0)) + packageManager.getApplicationInfo(packageName, 0) + ) itemView.setOnClickListener { - onBankSelected(packageName) + onBankSelected(packageName, deeplink) } } } companion object { - internal const val EXTRA_DEEPLINK = "extra_deeplink" - internal const val EXTRA_PACKAGE_NAME = "extra_package_name" + internal const val EXTRA_PAYMENT_ID = "extra_payment_id" + internal const val EXTRA_PAYMENT_DATA = "extra_payment_data" internal const val SBP_BANK_RESULT_CODE_NO_BANKS = 501 } } -sealed class BankListState { - object Shimmer : BankListState() - object Empty : BankListState() - class Error(val throwable: Throwable) : BankListState() - object NoNetwork : BankListState() - - class Content(val banks: List) : BankListState() -} - -object BankList { - - sealed class Result - class Success(val deeplink: String, val packageName: String) : Result() - class Canceled : Result() - class Error(val error: Throwable) : Result() - class NoBanks() : Result() - - - object Contract : ActivityResultContract() { - - override fun createIntent(context: Context, deeplink: String): Intent = - Intent(context, BankListActivity::class.java).apply { - putExtra(BankListActivity.EXTRA_DEEPLINK, deeplink) - } - - override fun parseResult(resultCode: Int, intent: Intent?): Result = when (resultCode) { - AppCompatActivity.RESULT_OK -> Success( - intent!!.getStringExtra(BankListActivity.EXTRA_DEEPLINK)!!, - intent.getStringExtra(BankListActivity.EXTRA_PACKAGE_NAME)!!) - TinkoffAcquiring.RESULT_ERROR -> Error(intent!!.getSerializableExtra(TinkoffAcquiring.EXTRA_ERROR)!! as Throwable) - SBP_BANK_RESULT_CODE_NO_BANKS -> NoBanks() - else -> Canceled() - } - } +sealed class SpbBankListState { + object Shimmer : SpbBankListState() + object Empty : SpbBankListState() + class Error(val throwable: Throwable) : SpbBankListState() + object NoNetwork : SpbBankListState() + class Content(val banks: List, val deeplink: String) : SpbBankListState() } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentViewModel.kt new file mode 100644 index 00000000..3bc1b24b --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/ui/SbpPaymentViewModel.kt @@ -0,0 +1,68 @@ +package ru.tinkoff.acquiring.sdk.redesign.sbp.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions +import ru.tinkoff.acquiring.sdk.payment.SbpPaymentProcess +import ru.tinkoff.acquiring.sdk.redesign.dialog.PaymentSheetStatus +import ru.tinkoff.acquiring.sdk.redesign.sbp.util.SbpStateMapper +import ru.tinkoff.acquiring.sdk.utils.ConnectionChecker +import ru.tinkoff.acquiring.sdk.utils.CoroutineManager +import ru.tinkoff.acquiring.sdk.utils.updateIfNotNull + +internal class SbpPaymentViewModel( + private val connectionChecker: ConnectionChecker, + private val sbpPaymentProcess: SbpPaymentProcess, + private val manager: CoroutineManager = CoroutineManager(), + private val stateMapper: SbpStateMapper = SbpStateMapper() +) : ViewModel() { + + val stateUiFlow = MutableStateFlow(SpbBankListState.Shimmer) + val paymentStateFlow = MutableStateFlow(PaymentSheetStatus.NotYet) + + init { + manager.launchOnBackground { + sbpPaymentProcess.state.collectLatest { + stateUiFlow.updateIfNotNull(stateMapper.mapUiState(it)) + paymentStateFlow.updateIfNotNull(stateMapper.mapStatusForm(it)) + } + } + } + + fun loadData(paymentOptions: PaymentOptions, paymentId: Long?) { + if (connectionChecker.isOnline().not()) { + stateUiFlow.value = SpbBankListState.NoNetwork + return + } + stateUiFlow.value = SpbBankListState.Shimmer + sbpPaymentProcess.start(paymentOptions, paymentId) + } + + fun onGoingToBankApp() { + sbpPaymentProcess.goingToBankApp() + } + + fun startCheckingStatus() { + sbpPaymentProcess.startCheckingStatus() + } + + fun cancelPayment() { + sbpPaymentProcess.stop() + } + + override fun onCleared() { + manager.cancelAll() + super.onCleared() + } + + companion object { + fun factory( + connectionChecker: ConnectionChecker, + ) = viewModelFactory { + initializer { SbpPaymentViewModel(connectionChecker, SbpPaymentProcess.get()) } + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/NspkBankAppsProvider.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/NspkBankAppsProvider.kt new file mode 100644 index 00000000..e87623f5 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/NspkBankAppsProvider.kt @@ -0,0 +1,5 @@ +package ru.tinkoff.acquiring.sdk.redesign.sbp.util + +fun interface NspkBankAppsProvider { + suspend fun getNspkApps() : Set +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/NspkInstalledAppsChecker.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/NspkInstalledAppsChecker.kt new file mode 100644 index 00000000..26bbcee6 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/NspkInstalledAppsChecker.kt @@ -0,0 +1,10 @@ +package ru.tinkoff.acquiring.sdk.redesign.sbp.util + +/** + * Created by i.golovachev + */ +fun interface NspkInstalledAppsChecker { + + fun checkInstalledApps(nspkBanks: Set, deeplink: String): List +} + diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpHelper.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpHelper.kt index ed852dc3..3976c25d 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpHelper.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpHelper.kt @@ -5,10 +5,6 @@ import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.FragmentManager -import ru.tinkoff.acquiring.sdk.redesign.dialog.OpenBankProgressDialogFragment -import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.BankListActivity object SbpHelper { @@ -23,6 +19,7 @@ object SbpHelper { val intent = Intent(Intent.ACTION_VIEW) intent.data = Uri.parse(deeplink) intent.setPackage(packageName) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); activity.startActivityForResult(intent, SBP_BANK_REQUEST_CODE) } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpStateMapper.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpStateMapper.kt new file mode 100644 index 00000000..1760a6d9 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/sbp/util/SbpStateMapper.kt @@ -0,0 +1,70 @@ +package ru.tinkoff.acquiring.sdk.redesign.sbp.util + +import ru.tinkoff.acquiring.sdk.R +import ru.tinkoff.acquiring.sdk.exceptions.AcquiringSdkTimeoutException +import ru.tinkoff.acquiring.sdk.models.enums.ResponseStatus +import ru.tinkoff.acquiring.sdk.payment.SbpPaymentState +import ru.tinkoff.acquiring.sdk.redesign.dialog.PaymentSheetStatus +import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.SpbBankListState + +/** + * Created by i.golovachev + */ +class SbpStateMapper { + + fun mapUiState(it: SbpPaymentState) = when (it) { + is SbpPaymentState.GetBankListFailed -> SpbBankListState.Error(it.throwable) + is SbpPaymentState.NeedChooseOnUi -> + if (it.bankList.isEmpty()) { + SpbBankListState.Empty + } else { + SpbBankListState.Content(it.bankList, it.deeplink) + } + else -> null + } + + fun mapStatusForm(it: SbpPaymentState): PaymentSheetStatus? { + return when (it) { + is SbpPaymentState.LeaveOnBankApp -> { + PaymentSheetStatus.Progress( + title = R.string.acq_commonsheet_payment_waiting_title, + secondButton = R.string.acq_commonsheet_payment_waiting_flat_button + ) + } + is SbpPaymentState.CheckingStatus -> { + val status = it.status + if (status == ResponseStatus.FORM_SHOWED) { + PaymentSheetStatus.Progress( + title = R.string.acq_commonsheet_payment_waiting_title, + secondButton = R.string.acq_commonsheet_payment_waiting_flat_button + ) + } else { + PaymentSheetStatus.Progress( + title = R.string.acq_commonsheet_processing_title, + subtitle = R.string.acq_commonsheet_processing_description + ) + } + } + is SbpPaymentState.Success -> + PaymentSheetStatus.Success(paymentId = it.paymentId) + is SbpPaymentState.PaymentFailed -> + if (it.throwable is AcquiringSdkTimeoutException) { + PaymentSheetStatus.Error( + title = R.string.acq_commonsheet_timeout_failed_title, + subtitle = R.string.acq_commonsheet_timeout_failed_description, + throwable = it.throwable, + secondButton = R.string.acq_commonsheet_timeout_failed_flat_button + ) + } else { + PaymentSheetStatus.Error( + title = R.string.acq_commonsheet_failed_title, + subtitle = R.string.acq_commonsheet_failed_description, + throwable = it.throwable, + mainButton = R.string.acq_commonsheet_failed_primary_button + ) + } + is SbpPaymentState.Stopped -> PaymentSheetStatus.Hide + else -> null + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt index 0ecc0bda..8eb5655e 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/CoroutineManager.kt @@ -26,37 +26,46 @@ import kotlin.coroutines.suspendCoroutine /** * @author Mariya Chernyadieva */ -internal class CoroutineManager(private val exceptionHandler: (Throwable) -> Unit, - private val io: CoroutineDispatcher = IO, - private val main : CoroutineDispatcher = Main) { +internal class CoroutineManager( + private val exceptionHandler: (Throwable) -> Unit, + private val io: CoroutineDispatcher = IO, + private val main: CoroutineDispatcher = Main +) { - constructor(io: CoroutineDispatcher = IO, - main : CoroutineDispatcher = Main) : this({}, io, main) + constructor( + io: CoroutineDispatcher = IO, + main: CoroutineDispatcher = Main + ) : this({}, io, main) private val job = SupervisorJob() - private val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable -> launchOnMain { exceptionHandler(throwable) } } + private val coroutineExceptionHandler = + CoroutineExceptionHandler { _, throwable -> launchOnMain { exceptionHandler(throwable) } } private val coroutineScope = CoroutineScope(Main + coroutineExceptionHandler + job) private val disposableSet = hashSetOf() - fun call(request: Request, onSuccess: (R) -> Unit, onFailure: ((Exception) -> Unit)? = null) { + fun call( + request: Request, + onSuccess: (R) -> Unit, + onFailure: ((Exception) -> Unit)? = null + ) { disposableSet.add(request) launchOnBackground { request.execute( - onSuccess = { - launchOnMain { - onSuccess(it) + onSuccess = { + launchOnMain { + onSuccess(it) + } + }, + onFailure = { + launchOnMain { + if (onFailure == null) { + exceptionHandler.invoke(it) + } else { + onFailure(it) } - }, - onFailure = { - launchOnMain { - if (onFailure == null) { - exceptionHandler.invoke(it) - } else { - onFailure(it) - } - } - }) + } + }) } } @@ -99,4 +108,19 @@ internal class CoroutineManager(private val exceptionHandler: (Throwable) -> Uni block.invoke(this) } } + + fun launchOnBackground( + block: suspend CoroutineScope.() -> Unit, + onError: (Throwable) -> Unit + ): Job { + return coroutineScope.launch(io) { + try { + block.invoke(this) + } catch (e: Throwable) { + if(e is CancellationException) { + onError(e) + } + } + } + } } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/FlowExt.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/FlowExt.kt new file mode 100644 index 00000000..e1d01d3b --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/FlowExt.kt @@ -0,0 +1,11 @@ +package ru.tinkoff.acquiring.sdk.utils + +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Created by i.golovachev + */ +fun MutableStateFlow.updateIfNotNull(value: T?) { + if (value == null) return + this.value = value +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/NspkClient.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/NspkClient.kt index 52883556..506b70d1 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/NspkClient.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/NspkClient.kt @@ -19,12 +19,14 @@ package ru.tinkoff.acquiring.sdk.utils import com.google.gson.Gson import com.google.gson.GsonBuilder import com.google.gson.JsonParseException +import okhttp3.OkHttpClient +import ru.tinkoff.acquiring.sdk.AcquiringSdk import ru.tinkoff.acquiring.sdk.exceptions.NetworkException import ru.tinkoff.acquiring.sdk.models.NspkResponse +import ru.tinkoff.acquiring.sdk.network.AcquiringApi import java.io.IOException -import java.io.InputStreamReader import java.net.HttpURLConnection -import java.net.URL +import java.util.concurrent.TimeUnit /** * @author Mariya Chernyadieva @@ -33,25 +35,35 @@ internal class NspkClient { companion object { private const val NSPK_ANDROID_APPS_URL = "https://qr.nspk.ru/.well-known/assetlinks.json" - private const val STREAM_BUFFER_SIZE = 4096 } - private val gson: Gson = GsonBuilder().create() + private val okHttpClient = OkHttpClient.Builder() + .connectTimeout(40000, TimeUnit.MILLISECONDS) + .readTimeout(40000, TimeUnit.MILLISECONDS) + .build() - fun call(request: Request, onSuccess: (NspkResponse) -> Unit, onFailure: (Exception) -> Unit) { - var responseReader: InputStreamReader? = null + private val gson: Gson = GsonBuilder().create() - try { - val targetUrl = URL(NSPK_ANDROID_APPS_URL) - val connection = targetUrl.openConnection() as HttpURLConnection - connection.requestMethod = "GET" - connection.connect() + fun call( + request: Request, + onSuccess: (NspkResponse) -> Unit, + onFailure: (Exception) -> Unit + ) { - val responseCode = connection.responseCode + val okHttpRequest = okhttp3.Request.Builder().url(NSPK_ANDROID_APPS_URL).get() + .header("User-Agent", System.getProperty("http.agent")!!) + .header("Accept", AcquiringApi.JSON) + .build() + val call = okHttpClient.newCall(okHttpRequest) + AcquiringSdk.log("=== Sending GET request to $NSPK_ANDROID_APPS_URL") + val okHttpResponse = call.execute() + val responseCode = okHttpResponse.code + val response = okHttpResponse.body?.string() + try { + AcquiringSdk.log("=== Got server response code: $responseCode") if (responseCode == HttpURLConnection.HTTP_OK) { - responseReader = InputStreamReader(connection.inputStream) - val response = read(responseReader) + AcquiringSdk.log("=== Got server response: $response") val banks: Set = (gson.fromJson(response, List::class.java) as List).map { ((it as Map<*, *>)["target"] as Map<*, *>)["package_name"] }.toSet() @@ -59,34 +71,22 @@ internal class NspkClient { onSuccess(NspkResponse(banks)) } } else { + AcquiringSdk.log("=== Got server response: $response") if (!request.isDisposed()) { onFailure(NetworkException("Got server error response code $responseCode")) } } } catch (e: IOException) { + AcquiringSdk.log("=== handle error on GET request to $NSPK_ANDROID_APPS_URL") if (!request.isDisposed()) { onFailure(e) } } catch (e: JsonParseException) { + AcquiringSdk.log("=== handle error on GET request to $NSPK_ANDROID_APPS_URL") if (!request.isDisposed()) { onFailure(e) } - } finally { - responseReader?.close() } } - - @Throws(IOException::class) - private fun read(reader: InputStreamReader): String { - val buffer = CharArray(STREAM_BUFFER_SIZE) - var read: Int = -1 - val result = StringBuilder() - - while ({ read = reader.read(buffer, 0, STREAM_BUFFER_SIZE); read }() != -1) { - result.append(buffer, 0, read) - } - - return result.toString() - } } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/QrViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/QrViewModel.kt index 8f005756..80de50f5 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/QrViewModel.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/viewmodel/QrViewModel.kt @@ -66,7 +66,7 @@ internal class QrViewModel( coroutine.call(request, onSuccess = { response -> - qrImageResult.value = response.data + qrImageResult.value = response.data!! changeScreenState(LoadedState) }) } @@ -99,7 +99,7 @@ internal class QrViewModel( onSuccess = { when (type) { DataTypeQr.IMAGE -> { - qrImageResult.value = it.data + qrImageResult.value = it.data!! coroutine.runWithDelay(15000) { getState(paymentId) } @@ -118,7 +118,7 @@ internal class QrViewModel( coroutine.call(request, onSuccess = { response -> if (response.status == ResponseStatus.CONFIRMED || response.status == ResponseStatus.AUTHORIZED) { - paymentResult.value = response.paymentId + paymentResult.value = response.paymentId!! } else { coroutine.runWithDelay(5000) { getState(paymentId) diff --git a/ui/src/main/res/drawable/acq_button_flat_bg.xml b/ui/src/main/res/drawable/acq_button_flat_bg.xml index 4a7587a7..d1966ce0 100644 --- a/ui/src/main/res/drawable/acq_button_flat_bg.xml +++ b/ui/src/main/res/drawable/acq_button_flat_bg.xml @@ -33,7 +33,8 @@ - + + diff --git a/ui/src/main/res/layout/acq_fragment_payment_failure_insufficient_funds.xml b/ui/src/main/res/layout/acq_payment_status_form.xml similarity index 58% rename from ui/src/main/res/layout/acq_fragment_payment_failure_insufficient_funds.xml rename to ui/src/main/res/layout/acq_payment_status_form.xml index c54b5e2f..6dbca2b6 100644 --- a/ui/src/main/res/layout/acq_fragment_payment_failure_insufficient_funds.xml +++ b/ui/src/main/res/layout/acq_payment_status_form.xml @@ -1,39 +1,54 @@ + android:orientation="vertical" + android:paddingBottom="24dp"> + + + android:textStyle="bold" + tools:text="Не получилось оплатить —\nнедостаточно денег на счету" /> + android:textSize="16sp" + tools:text="Пополните его или выберите другой" /> - \ No newline at end of file diff --git a/ui/src/main/res/values-ru/strings.xml b/ui/src/main/res/values-ru/strings.xml index a9689e5a..62d9a36d 100644 --- a/ui/src/main/res/values-ru/strings.xml +++ b/ui/src/main/res/values-ru/strings.xml @@ -81,7 +81,16 @@ Это займет некоторое время Оплачено Не получилось оплатить + Попробуйте другой способ оплаты + Выбрать другой способ оплаты Понятно Воспользуйтесь другим\nспособом оплаты + Ждем оплату в приложении банка + Закрыть + + Время оплаты истекло + Попробуйте оплатить снова или выберите другой способ оплаты + Оплатить еще раз + Другой способ оплаты \ No newline at end of file diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 9ab22402..6d1a9240 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -29,9 +29,19 @@ Processing the payment it will take some time Paid + + Payment time has expired + Try to pay again or choose another payment method + Pay again + Other payment method + Payment error + Try to pay again or choose another payment method + Pay again OK Use a different payment method + Waiting for payment in the bank application + Close %1$s • %2$s Your cards diff --git a/ui/src/main/res/values/styles.xml b/ui/src/main/res/values/styles.xml index 0e936c7a..8552bd95 100644 --- a/ui/src/main/res/values/styles.xml +++ b/ui/src/main/res/values/styles.xml @@ -283,4 +283,11 @@ 32dp + + + diff --git a/ui/src/test/java/common/AssertExt.kt b/ui/src/test/java/common/AssertExt.kt index d6dc8193..e5ab1eac 100644 --- a/ui/src/test/java/common/AssertExt.kt +++ b/ui/src/test/java/common/AssertExt.kt @@ -9,6 +9,6 @@ fun assertByClassName(expected: Any?, actual: Any?) { Assert.assertEquals(expected?.javaClass?.simpleName, actual?.javaClass?.simpleName) } -fun assertByClassName(expected: Class<*>, actual: Class<*>) { - Assert.assertEquals(expected?.javaClass?.simpleName, actual?.javaClass?.simpleName) +inline fun assertViaClassName(expected: Class, actual: T) { + Assert.assertEquals(expected.simpleName, actual::class.java.simpleName) } \ No newline at end of file diff --git a/ui/src/test/java/sbp/SbpTestEnvironment.kt b/ui/src/test/java/sbp/SbpTestEnvironment.kt new file mode 100644 index 00000000..1d539eb0 --- /dev/null +++ b/ui/src/test/java/sbp/SbpTestEnvironment.kt @@ -0,0 +1,102 @@ +package sbp + +import kotlinx.coroutines.* +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import ru.tinkoff.acquiring.sdk.AcquiringSdk +import ru.tinkoff.acquiring.sdk.payment.SbpPaymentProcess +import ru.tinkoff.acquiring.sdk.redesign.sbp.ui.SbpPaymentViewModel +import ru.tinkoff.acquiring.sdk.redesign.sbp.util.NspkBankAppsProvider +import ru.tinkoff.acquiring.sdk.redesign.sbp.util.NspkInstalledAppsChecker +import ru.tinkoff.acquiring.sdk.requests.GetQrRequest +import ru.tinkoff.acquiring.sdk.requests.GetStateRequest +import ru.tinkoff.acquiring.sdk.requests.InitRequest +import ru.tinkoff.acquiring.sdk.requests.performSuspendRequest +import ru.tinkoff.acquiring.sdk.responses.GetQrResponse +import ru.tinkoff.acquiring.sdk.responses.InitResponse +import ru.tinkoff.acquiring.sdk.utils.ConnectionChecker +import ru.tinkoff.acquiring.sdk.utils.CoroutineManager + +val nspkApps = setOf("ru.nspk.sbpay") + + +/** + * Created by i.golovachev + */ +internal class SbpTestEnvironment( + val connectionChecker: ConnectionChecker = mock { + on { isOnline() } doReturn true + }, + val bankAppsProvider: NspkInstalledAppsChecker = NspkInstalledAppsChecker { _, _ -> nspkApps.toList() }, + val nspkBankAppsProvider: NspkBankAppsProvider = NspkBankAppsProvider { nspkApps }, + + // env + val dispatcher: CoroutineDispatcher = Dispatchers.Unconfined, + val processJob: Job = SupervisorJob(), + val paymentId: Long = 1, + val deeplink: String = "https://qr.nspk.ru/test_link", + + + // requests + val initRequest: InitRequest = mock(), + val getQrRequest: GetQrRequest = mock(), + val getState: GetStateRequest = mock() +) { + val sdk: AcquiringSdk = mock { + on { init(any()) } doReturn initRequest + on { getQr(any()) } doReturn getQrRequest + on { getState(any()) } doReturn getState + } + + val sbpPaymentProgress = SbpPaymentProcess(sdk, bankAppsProvider, nspkBankAppsProvider, CoroutineScope( dispatcher + processJob)) + val viewModel: SbpPaymentViewModel = SbpPaymentViewModel( + connectionChecker, + sbpPaymentProgress, + CoroutineManager(dispatcher, dispatcher) + ) + + suspend fun setInitResult(throwable: Throwable) { + val result = Result.failure(throwable) + + whenever(initRequest.performSuspendRequest()) + .doReturn(result) + } + + suspend fun setInitResult(definePaymentId: Long? = null) { + val response = InitResponse(paymentId = definePaymentId) + val result = Result.success(response) + + whenever(initRequest.performSuspendRequest()) + .doReturn(result) + } + + suspend fun setGetQrResult(throwable: Throwable) { + val result = Result.failure(throwable) + + whenever(getQrRequest.performSuspendRequest()) + .doReturn(result) + } + + suspend fun setGetQrResult(deeplink: String) { + val response = GetQrResponse(data = deeplink) + val result = Result.success(response) + + whenever(getQrRequest.performSuspendRequest()) + .doReturn(result) + } +} + +internal fun SbpTestEnvironment.runWithEnv( + given: suspend SbpTestEnvironment.() -> Unit, + `when`: suspend SbpTestEnvironment.() -> Unit, + then: suspend SbpTestEnvironment.() -> Unit +) { + runBlocking { + launch { given.invoke(this@runWithEnv) }.join() + launch { `when`.invoke(this@runWithEnv) }.join() + launch { then.invoke(this@runWithEnv) }.join() + } +} + diff --git a/ui/src/test/java/sbp/ShowBanksApps.kt b/ui/src/test/java/sbp/ShowBanksApps.kt new file mode 100644 index 00000000..f6ece9c1 --- /dev/null +++ b/ui/src/test/java/sbp/ShowBanksApps.kt @@ -0,0 +1,28 @@ +package sbp + +import common.assertViaClassName +import org.junit.Test +import org.mockito.kotlin.mock +import ru.tinkoff.acquiring.sdk.payment.SbpPaymentState + +/** + * Created by i.golovachev + */ +class ShowBanksApps { + + @Test + fun `check progress WHEN start screen`() { + SbpTestEnvironment().runWithEnv( + given = { + setInitResult(definePaymentId = paymentId) + setGetQrResult(deeplink = deeplink) + }, + `when` = { + sbpPaymentProgress.start(mock()) + }, + then = { + assertViaClassName(SbpPaymentState.NeedChooseOnUi::class.java, sbpPaymentProgress.state.value) + } + ) + } +} \ No newline at end of file From 707f0b99b69a496f2617888d655694aae7873479 Mon Sep 17 00:00:00 2001 From: jqwout Date: Thu, 2 Feb 2023 20:24:05 +0300 Subject: [PATCH 033/126] =?UTF-8?q?MC-8148=20=D1=84=D0=BB=D0=BE=D1=83=20?= =?UTF-8?q?=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=B0=D0=BC=D0=B8=202.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sdk/requests/RemoveCardRequest.kt | 4 +- .../list/presentation/CardsListViewModel.kt | 11 ++-- .../cards/list/ui/CardsListActivity.kt | 35 +++++++++--- .../redesign/cards/list/ui/CardsListState.kt | 6 +- .../carddatainput/CardDataInputFragment.kt | 18 ++++++ .../acquiring/sdk/utils/AcqSnackBarHelper.kt | 4 +- .../res/layout/acq_activity_card_list.xml | 57 +++++++++++-------- ui/src/main/res/values-ru/strings.xml | 10 ++-- ui/src/main/res/values/strings.xml | 12 ++-- 9 files changed, 105 insertions(+), 52 deletions(-) diff --git a/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/RemoveCardRequest.kt b/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/RemoveCardRequest.kt index 334318b1..3b41361b 100644 --- a/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/RemoveCardRequest.kt +++ b/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/RemoveCardRequest.kt @@ -17,6 +17,7 @@ package ru.tinkoff.acquiring.sdk.requests import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf import ru.tinkoff.acquiring.sdk.network.AcquiringApi.REMOVE_CARD_METHOD import ru.tinkoff.acquiring.sdk.responses.RemoveCardResponse import ru.tinkoff.acquiring.sdk.utils.RequestResult @@ -63,6 +64,7 @@ class RemoveCardRequest : AcquiringRequest(REMOVE_CARD_METHO * Реактивный вызов метода API */ fun executeFlow(): Flow> { - return super.performRequestFlow(this, RemoveCardResponse::class.java) + return flowOf(RequestResult.Failure(Exception())) + // return super.performRequestFlow(this, RemoveCardResponse::class.java) } } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt index 843fc134..6ebe2c0f 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt @@ -3,6 +3,7 @@ package ru.tinkoff.acquiring.sdk.redesign.cards.list.presentation import androidx.annotation.VisibleForTesting import androidx.lifecycle.ViewModel import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import ru.tinkoff.acquiring.sdk.AcquiringSdk import ru.tinkoff.acquiring.sdk.models.Card @@ -39,13 +40,13 @@ internal class CardsListViewModel( fun loadData(customerKey: String?, recurrentOnly: Boolean) { if (connectionChecker.isOnline().not()) { - stateFlow.tryEmit(CardsListState.NoNetwork) + stateFlow.value = CardsListState.NoNetwork return } - stateFlow.tryEmit(CardsListState.Shimmer) + stateFlow.value = CardsListState.Shimmer manager.launchOnBackground { if (customerKey == null) { - stateFlow.tryEmit(CardsListState.Error(Throwable())) + stateFlow.value = CardsListState.Error(Throwable()) return@launchOnBackground } @@ -63,7 +64,7 @@ internal class CardsListViewModel( return } - eventFlow.value = CardListEvent.RemoveCardProgress + eventFlow.value = CardListEvent.RemoveCardProgress(model) deleteJob = manager.launchOnBackground { if (connectionChecker.isOnline().not()) { eventFlow.value = CardListEvent.ShowError @@ -88,7 +89,7 @@ internal class CardsListViewModel( val list = checkNotNull((stateFlow.value as? CardsListState.Content)?.cards) stateFlow.update { CardsListState.Content(it.mode, true, list) } - eventFlow.value = CardListEvent.ShowError + eventFlow.value = CardListEvent.ShowCardDeleteError(it) deleteJob?.cancel() } ) diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt index 8f9d5970..87cedc1e 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt @@ -39,7 +39,7 @@ internal class CardsListActivity : TransparentActivity() { private val recyclerView: RecyclerView by lazyView(R.id.acq_card_list_view) private val viewFlipper: ViewFlipper by lazyView(R.id.acq_view_flipper) private val cardShimmer: ViewGroup by lazyView(R.id.acq_card_list_shimmer) - private val root: ViewGroup by lazyView(R.id.acq_card_list_root) + private val root: ViewGroup by lazyView(R.id.acq_card_list_base) private val stubImage: ImageView by lazyView(R.id.acq_stub_img) private val stubTitleView: TextView by lazyView(R.id.acq_stub_title) private val stubSubtitleView: TextView by lazyView(R.id.acq_stub_subtitle) @@ -48,7 +48,7 @@ internal class CardsListActivity : TransparentActivity() { private lateinit var cardsListAdapter: CardsListAdapter private val snackBarHelper: AcqSnackBarHelper by lazyUnsafe { - AcqSnackBarHelper(root) + AcqSnackBarHelper(findViewById(R.id.acq_card_list_root)) } private val attachCard = registerForActivityResult(AttachCard.Contract) { result -> @@ -84,10 +84,12 @@ internal class CardsListActivity : TransparentActivity() { setContentView(R.layout.acq_activity_card_list) viewModel = provideViewModel(CardsListViewModel::class.java) as CardsListViewModel - viewModel.loadData( - savedCardsOptions.customer.customerKey, - options.features.showOnlyRecurrentCards - ) + if (savedInstanceState == null) { + viewModel.loadData( + savedCardsOptions.customer.customerKey, + options.features.showOnlyRecurrentCards + ) + } initToolbar() initViews() @@ -229,7 +231,10 @@ internal class CardsListActivity : TransparentActivity() { private fun CoroutineScope.subscribeOnEvents() { launch { viewModel.eventFlow.filterNotNull().collect { - handleDeleteInProgress(it is CardListEvent.RemoveCardProgress) + handleDeleteInProgress( + it is CardListEvent.RemoveCardProgress, + (it as? CardListEvent.RemoveCardProgress)?.deletedCard?.tail + ) when (it) { is CardListEvent.RemoveCardProgress -> Unit is CardListEvent.RemoveCardSuccess -> { @@ -249,6 +254,13 @@ internal class CardsListActivity : TransparentActivity() { is CardListEvent.CloseScreen -> { finish() } + is CardListEvent.ShowCardDeleteError -> { + showErrorDialog( + R.string.acq_cardlist_alert_deletecard_label, + null, + R.string.acq_generic_alert_access + ) + } } } } @@ -288,14 +300,19 @@ internal class CardsListActivity : TransparentActivity() { super.finish() } - private fun handleDeleteInProgress(inProgress: Boolean) { + private fun handleDeleteInProgress(inProgress: Boolean, cardTail: String?) { root.alpha = if (inProgress) 0.5f else 1f if (inProgress) { window.setFlags( WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE, WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE ) - snackBarHelper.showProgress(R.string.acq_cardlist_snackbar_remove_progress) + snackBarHelper.showProgress( + getString( + R.string.acq_cardlist_snackbar_remove_progress, + cardTail + ) + ) } else { window.clearFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE) snackBarHelper.hide() diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt index d92b6f5e..9a337570 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt @@ -19,7 +19,9 @@ sealed class CardsListState(val mode: CardListMode, val isInternal: Boolean = fa } sealed class CardListEvent { - object RemoveCardProgress : CardListEvent() + class RemoveCardProgress( + val deletedCard: CardItemUiModel + ) : CardListEvent() class RemoveCardSuccess( val deletedCard: CardItemUiModel, @@ -27,6 +29,8 @@ sealed class CardListEvent { object ShowError : CardListEvent() + class ShowCardDeleteError(val it: Throwable) : CardListEvent() + object CloseScreen : CardListEvent() } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt index cb0f93f6..b6e575da 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt @@ -125,6 +125,12 @@ internal class CardDataInputFragment : Fragment() { cardNumberInput.requestViewFocus() + savedInstanceState?.run { + cardNumberInput.text = getString(SAVE_CARD_NUMBER, cardNumber) + expiryDateInput.text = getString(SAVE_EXPIRY_DATE, expiryDate) + cvcInput.text = getString(SAVE_CVC, cvc) + } + onDataChanged() } @@ -133,6 +139,14 @@ internal class CardDataInputFragment : Fragment() { cardScanner = CardScanner(context) } + override fun onSaveInstanceState(outState: Bundle) { + with(outState) { + putString(SAVE_CARD_NUMBER, cardNumber) + putString(SAVE_EXPIRY_DATE, expiryDate) + putString(SAVE_CVC, cvc) + } + } + // todo results api override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { when (requestCode) { @@ -202,6 +216,10 @@ internal class CardDataInputFragment : Fragment() { const val EXPIRY_DATE_MASK = "__/__" const val CVC_MASK = "___" + private const val SAVE_CARD_NUMBER = "extra_card_number" + private const val SAVE_EXPIRY_DATE = "extra_expiry_date" + private const val SAVE_CVC = "extra_save_cvc" + fun shouldAutoSwitchFromCardNumber(cardNumber: String, paymentSystem: CardPaymentSystem): Boolean { if (cardNumber.length == paymentSystem.range.last) return true diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBarHelper.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBarHelper.kt index 04b1261b..35c4963c 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBarHelper.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBarHelper.kt @@ -22,7 +22,7 @@ class AcqSnackBarHelper(private val view: View) { fun showProgress(textValue: String) { snackbar?.takeIf { it.isShown }?.dismiss() - val bar = Snackbar.make(view, "", Snackbar.LENGTH_LONG).apply { snackbar = this } + val bar = Snackbar.make(view, "", Snackbar.LENGTH_INDEFINITE).apply { snackbar = this } val customSnackView: View = LayoutInflater.from(view.context).inflate(R.layout.acq_snackbar_progress_layout, null) val textView = customSnackView.findViewById(R.id.acq_snackbar_text) @@ -51,7 +51,7 @@ class AcqSnackBarHelper(private val view: View) { fun showWithIcon(@DrawableRes iconRes: Int, textValue: String) { snackbar?.takeIf { it.isShown }?.dismiss() - val bar = Snackbar.make(view, "", Snackbar.LENGTH_SHORT).apply { snackbar = this } + val bar = Snackbar.make(view, "", Snackbar.LENGTH_LONG).apply { snackbar = this } val customSnackView: View = LayoutInflater.from(view.context).inflate(R.layout.acq_snackbar_with_icon_layout, null) val textView = customSnackView.findViewById(R.id.acq_snackbar_text) diff --git a/ui/src/main/res/layout/acq_activity_card_list.xml b/ui/src/main/res/layout/acq_activity_card_list.xml index ffa8a88d..6ff40a6f 100644 --- a/ui/src/main/res/layout/acq_activity_card_list.xml +++ b/ui/src/main/res/layout/acq_activity_card_list.xml @@ -13,38 +13,45 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> - + android:layout_height="match_parent"> - + android:layout_height="match_parent" + android:background="?colorPrimary" + android:orientation="vertical"> - + + + + + - + - + - + - + - \ No newline at end of file + \ No newline at end of file diff --git a/ui/src/main/res/values-ru/strings.xml b/ui/src/main/res/values-ru/strings.xml index 62d9a36d..c4fd3802 100644 --- a/ui/src/main/res/values-ru/strings.xml +++ b/ui/src/main/res/values-ru/strings.xml @@ -15,7 +15,7 @@ ~ limitations under the License. --> - + Для сканирования карты необходимо включить NFC NFC выключен @@ -44,13 +44,15 @@ Код Добавить - Карта •%1$s добавлена - Карта •%1$s удалена - Удаляем карту + Карта ·%1$s добавлена + Карта ·%1$s удалена + Удаляем карту ·%1$s Изменить Готово + Не получилось удалить карту + Ошибка добавления карты diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 6d1a9240..b8da2486 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -14,7 +14,7 @@ ~ limitations under the License. --> - + Turn on NFC to scan a card NFC is turn off @@ -54,7 +54,7 @@ Try again in a couple of minutes - Clear + OK Not loaded @@ -63,13 +63,15 @@ This is where your cards will be Add - Card •%1$s added - Card •%1$s deleted - Delete in progress + Card ·%1$s added + Card ·%1$s deleted + Card is deleting ·%1$s Add new Сhange Done + Couldn\'t delete the card + Error attaching card From eba195280fd71ecd0c27e30b1069bfb726186a3d Mon Sep 17 00:00:00 2001 From: jqwout Date: Thu, 2 Feb 2023 20:24:05 +0300 Subject: [PATCH 034/126] =?UTF-8?q?MC-8148=20=D1=84=D0=BB=D0=BE=D1=83=20?= =?UTF-8?q?=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=B0=D0=BC=D0=B8=202.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sdk/requests/RemoveCardRequest.kt | 1 + .../list/presentation/CardsListViewModel.kt | 11 ++-- .../cards/list/ui/CardsListActivity.kt | 35 +++++++++--- .../redesign/cards/list/ui/CardsListState.kt | 6 +- .../carddatainput/CardDataInputFragment.kt | 18 ++++++ .../acquiring/sdk/utils/AcqSnackBarHelper.kt | 4 +- .../res/layout/acq_activity_card_list.xml | 57 +++++++++++-------- ui/src/main/res/values-ru/strings.xml | 10 ++-- ui/src/main/res/values/strings.xml | 12 ++-- 9 files changed, 103 insertions(+), 51 deletions(-) diff --git a/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/RemoveCardRequest.kt b/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/RemoveCardRequest.kt index 334318b1..28b78cf1 100644 --- a/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/RemoveCardRequest.kt +++ b/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/RemoveCardRequest.kt @@ -17,6 +17,7 @@ package ru.tinkoff.acquiring.sdk.requests import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf import ru.tinkoff.acquiring.sdk.network.AcquiringApi.REMOVE_CARD_METHOD import ru.tinkoff.acquiring.sdk.responses.RemoveCardResponse import ru.tinkoff.acquiring.sdk.utils.RequestResult diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt index 843fc134..6ebe2c0f 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt @@ -3,6 +3,7 @@ package ru.tinkoff.acquiring.sdk.redesign.cards.list.presentation import androidx.annotation.VisibleForTesting import androidx.lifecycle.ViewModel import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import ru.tinkoff.acquiring.sdk.AcquiringSdk import ru.tinkoff.acquiring.sdk.models.Card @@ -39,13 +40,13 @@ internal class CardsListViewModel( fun loadData(customerKey: String?, recurrentOnly: Boolean) { if (connectionChecker.isOnline().not()) { - stateFlow.tryEmit(CardsListState.NoNetwork) + stateFlow.value = CardsListState.NoNetwork return } - stateFlow.tryEmit(CardsListState.Shimmer) + stateFlow.value = CardsListState.Shimmer manager.launchOnBackground { if (customerKey == null) { - stateFlow.tryEmit(CardsListState.Error(Throwable())) + stateFlow.value = CardsListState.Error(Throwable()) return@launchOnBackground } @@ -63,7 +64,7 @@ internal class CardsListViewModel( return } - eventFlow.value = CardListEvent.RemoveCardProgress + eventFlow.value = CardListEvent.RemoveCardProgress(model) deleteJob = manager.launchOnBackground { if (connectionChecker.isOnline().not()) { eventFlow.value = CardListEvent.ShowError @@ -88,7 +89,7 @@ internal class CardsListViewModel( val list = checkNotNull((stateFlow.value as? CardsListState.Content)?.cards) stateFlow.update { CardsListState.Content(it.mode, true, list) } - eventFlow.value = CardListEvent.ShowError + eventFlow.value = CardListEvent.ShowCardDeleteError(it) deleteJob?.cancel() } ) diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt index 8f9d5970..87cedc1e 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListActivity.kt @@ -39,7 +39,7 @@ internal class CardsListActivity : TransparentActivity() { private val recyclerView: RecyclerView by lazyView(R.id.acq_card_list_view) private val viewFlipper: ViewFlipper by lazyView(R.id.acq_view_flipper) private val cardShimmer: ViewGroup by lazyView(R.id.acq_card_list_shimmer) - private val root: ViewGroup by lazyView(R.id.acq_card_list_root) + private val root: ViewGroup by lazyView(R.id.acq_card_list_base) private val stubImage: ImageView by lazyView(R.id.acq_stub_img) private val stubTitleView: TextView by lazyView(R.id.acq_stub_title) private val stubSubtitleView: TextView by lazyView(R.id.acq_stub_subtitle) @@ -48,7 +48,7 @@ internal class CardsListActivity : TransparentActivity() { private lateinit var cardsListAdapter: CardsListAdapter private val snackBarHelper: AcqSnackBarHelper by lazyUnsafe { - AcqSnackBarHelper(root) + AcqSnackBarHelper(findViewById(R.id.acq_card_list_root)) } private val attachCard = registerForActivityResult(AttachCard.Contract) { result -> @@ -84,10 +84,12 @@ internal class CardsListActivity : TransparentActivity() { setContentView(R.layout.acq_activity_card_list) viewModel = provideViewModel(CardsListViewModel::class.java) as CardsListViewModel - viewModel.loadData( - savedCardsOptions.customer.customerKey, - options.features.showOnlyRecurrentCards - ) + if (savedInstanceState == null) { + viewModel.loadData( + savedCardsOptions.customer.customerKey, + options.features.showOnlyRecurrentCards + ) + } initToolbar() initViews() @@ -229,7 +231,10 @@ internal class CardsListActivity : TransparentActivity() { private fun CoroutineScope.subscribeOnEvents() { launch { viewModel.eventFlow.filterNotNull().collect { - handleDeleteInProgress(it is CardListEvent.RemoveCardProgress) + handleDeleteInProgress( + it is CardListEvent.RemoveCardProgress, + (it as? CardListEvent.RemoveCardProgress)?.deletedCard?.tail + ) when (it) { is CardListEvent.RemoveCardProgress -> Unit is CardListEvent.RemoveCardSuccess -> { @@ -249,6 +254,13 @@ internal class CardsListActivity : TransparentActivity() { is CardListEvent.CloseScreen -> { finish() } + is CardListEvent.ShowCardDeleteError -> { + showErrorDialog( + R.string.acq_cardlist_alert_deletecard_label, + null, + R.string.acq_generic_alert_access + ) + } } } } @@ -288,14 +300,19 @@ internal class CardsListActivity : TransparentActivity() { super.finish() } - private fun handleDeleteInProgress(inProgress: Boolean) { + private fun handleDeleteInProgress(inProgress: Boolean, cardTail: String?) { root.alpha = if (inProgress) 0.5f else 1f if (inProgress) { window.setFlags( WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE, WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE ) - snackBarHelper.showProgress(R.string.acq_cardlist_snackbar_remove_progress) + snackBarHelper.showProgress( + getString( + R.string.acq_cardlist_snackbar_remove_progress, + cardTail + ) + ) } else { window.clearFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE) snackBarHelper.hide() diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt index d92b6f5e..9a337570 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/ui/CardsListState.kt @@ -19,7 +19,9 @@ sealed class CardsListState(val mode: CardListMode, val isInternal: Boolean = fa } sealed class CardListEvent { - object RemoveCardProgress : CardListEvent() + class RemoveCardProgress( + val deletedCard: CardItemUiModel + ) : CardListEvent() class RemoveCardSuccess( val deletedCard: CardItemUiModel, @@ -27,6 +29,8 @@ sealed class CardListEvent { object ShowError : CardListEvent() + class ShowCardDeleteError(val it: Throwable) : CardListEvent() + object CloseScreen : CardListEvent() } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt index cb0f93f6..b6e575da 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt @@ -125,6 +125,12 @@ internal class CardDataInputFragment : Fragment() { cardNumberInput.requestViewFocus() + savedInstanceState?.run { + cardNumberInput.text = getString(SAVE_CARD_NUMBER, cardNumber) + expiryDateInput.text = getString(SAVE_EXPIRY_DATE, expiryDate) + cvcInput.text = getString(SAVE_CVC, cvc) + } + onDataChanged() } @@ -133,6 +139,14 @@ internal class CardDataInputFragment : Fragment() { cardScanner = CardScanner(context) } + override fun onSaveInstanceState(outState: Bundle) { + with(outState) { + putString(SAVE_CARD_NUMBER, cardNumber) + putString(SAVE_EXPIRY_DATE, expiryDate) + putString(SAVE_CVC, cvc) + } + } + // todo results api override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { when (requestCode) { @@ -202,6 +216,10 @@ internal class CardDataInputFragment : Fragment() { const val EXPIRY_DATE_MASK = "__/__" const val CVC_MASK = "___" + private const val SAVE_CARD_NUMBER = "extra_card_number" + private const val SAVE_EXPIRY_DATE = "extra_expiry_date" + private const val SAVE_CVC = "extra_save_cvc" + fun shouldAutoSwitchFromCardNumber(cardNumber: String, paymentSystem: CardPaymentSystem): Boolean { if (cardNumber.length == paymentSystem.range.last) return true diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBarHelper.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBarHelper.kt index 04b1261b..35c4963c 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBarHelper.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/AcqSnackBarHelper.kt @@ -22,7 +22,7 @@ class AcqSnackBarHelper(private val view: View) { fun showProgress(textValue: String) { snackbar?.takeIf { it.isShown }?.dismiss() - val bar = Snackbar.make(view, "", Snackbar.LENGTH_LONG).apply { snackbar = this } + val bar = Snackbar.make(view, "", Snackbar.LENGTH_INDEFINITE).apply { snackbar = this } val customSnackView: View = LayoutInflater.from(view.context).inflate(R.layout.acq_snackbar_progress_layout, null) val textView = customSnackView.findViewById(R.id.acq_snackbar_text) @@ -51,7 +51,7 @@ class AcqSnackBarHelper(private val view: View) { fun showWithIcon(@DrawableRes iconRes: Int, textValue: String) { snackbar?.takeIf { it.isShown }?.dismiss() - val bar = Snackbar.make(view, "", Snackbar.LENGTH_SHORT).apply { snackbar = this } + val bar = Snackbar.make(view, "", Snackbar.LENGTH_LONG).apply { snackbar = this } val customSnackView: View = LayoutInflater.from(view.context).inflate(R.layout.acq_snackbar_with_icon_layout, null) val textView = customSnackView.findViewById(R.id.acq_snackbar_text) diff --git a/ui/src/main/res/layout/acq_activity_card_list.xml b/ui/src/main/res/layout/acq_activity_card_list.xml index ffa8a88d..6ff40a6f 100644 --- a/ui/src/main/res/layout/acq_activity_card_list.xml +++ b/ui/src/main/res/layout/acq_activity_card_list.xml @@ -13,38 +13,45 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> - + android:layout_height="match_parent"> - + android:layout_height="match_parent" + android:background="?colorPrimary" + android:orientation="vertical"> - + + + + + - + - + - + - + - \ No newline at end of file + \ No newline at end of file diff --git a/ui/src/main/res/values-ru/strings.xml b/ui/src/main/res/values-ru/strings.xml index 62d9a36d..c4fd3802 100644 --- a/ui/src/main/res/values-ru/strings.xml +++ b/ui/src/main/res/values-ru/strings.xml @@ -15,7 +15,7 @@ ~ limitations under the License. --> - + Для сканирования карты необходимо включить NFC NFC выключен @@ -44,13 +44,15 @@ Код Добавить - Карта •%1$s добавлена - Карта •%1$s удалена - Удаляем карту + Карта ·%1$s добавлена + Карта ·%1$s удалена + Удаляем карту ·%1$s Изменить Готово + Не получилось удалить карту + Ошибка добавления карты diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 6d1a9240..b8da2486 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -14,7 +14,7 @@ ~ limitations under the License. --> - + Turn on NFC to scan a card NFC is turn off @@ -54,7 +54,7 @@ Try again in a couple of minutes - Clear + OK Not loaded @@ -63,13 +63,15 @@ This is where your cards will be Add - Card •%1$s added - Card •%1$s deleted - Delete in progress + Card ·%1$s added + Card ·%1$s deleted + Card is deleting ·%1$s Add new Сhange Done + Couldn\'t delete the card + Error attaching card From 18745f6cb4025ee22d3adbeb7278f6af7c191c46 Mon Sep 17 00:00:00 2001 From: jqwout Date: Fri, 3 Feb 2023 14:20:28 +0300 Subject: [PATCH 035/126] =?UTF-8?q?MC-8148=20=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=20=D1=81=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B5=D0=B9=20=D0=BA=D0=B0=D1=80=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sdk/redesign/common/carddatainput/CardDataInputFragment.kt | 3 +-- .../sdk/redesign/common/carddatainput/CardNumberFormatter.kt | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt index b6e575da..4b05057c 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt @@ -69,8 +69,7 @@ internal class CardDataInputFragment : Fragment() { if (cardNumber.length in paymentSystem.range) { if (!CardValidator.validateCardNumber(cardNumber)) { errorHighlighted = true - } else if (cardNumberFormatter.isSingleInsert && - shouldAutoSwitchFromCardNumber(cardNumber, paymentSystem)) { + } else if (cardNumberFormatter.isSingleInsert || shouldAutoSwitchFromCardNumber(cardNumber, paymentSystem)) { expiry_date_input.requestViewFocus() } } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardNumberFormatter.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardNumberFormatter.kt index b0aced4e..e518df5d 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardNumberFormatter.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardNumberFormatter.kt @@ -12,6 +12,7 @@ internal class CardNumberFormatter : TextWatcher { var isSingleInsert = false private var deleteAt = -1 + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { prev = normalize(s?.toString()) } From 129c58d1b7c22c36caec0e3b03d9eec075b9167b Mon Sep 17 00:00:00 2001 From: jqwout Date: Fri, 3 Feb 2023 15:08:29 +0300 Subject: [PATCH 036/126] =?UTF-8?q?MC-8148=20=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=20=D1=81=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B5=D0=B9=20=D0=BA=D0=B0=D1=80=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sdk/requests/RemoveCardRequest.kt | 1 - .../list/presentation/CardsListViewModel.kt | 1 - .../carddatainput/CardNumberFormatter.kt | 18 ++++++++++++++---- .../cards/list/CardsDeleteViewModelTest.kt | 6 +++--- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/RemoveCardRequest.kt b/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/RemoveCardRequest.kt index 28b78cf1..334318b1 100644 --- a/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/RemoveCardRequest.kt +++ b/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/RemoveCardRequest.kt @@ -17,7 +17,6 @@ package ru.tinkoff.acquiring.sdk.requests import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf import ru.tinkoff.acquiring.sdk.network.AcquiringApi.REMOVE_CARD_METHOD import ru.tinkoff.acquiring.sdk.responses.RemoveCardResponse import ru.tinkoff.acquiring.sdk.utils.RequestResult diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt index 6ebe2c0f..b7836e28 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/presentation/CardsListViewModel.kt @@ -3,7 +3,6 @@ package ru.tinkoff.acquiring.sdk.redesign.cards.list.presentation import androidx.annotation.VisibleForTesting import androidx.lifecycle.ViewModel import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import ru.tinkoff.acquiring.sdk.AcquiringSdk import ru.tinkoff.acquiring.sdk.models.Card diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardNumberFormatter.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardNumberFormatter.kt index e518df5d..0e980c92 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardNumberFormatter.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardNumberFormatter.kt @@ -11,7 +11,7 @@ internal class CardNumberFormatter : TextWatcher { private var prev: CharSequence? = null var isSingleInsert = false private var deleteAt = -1 - + private var prevPayment: CardPaymentSystem = CardPaymentSystem.UNKNOWN override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { prev = normalize(s?.toString()) @@ -30,10 +30,10 @@ internal class CardNumberFormatter : TextWatcher { cardNumber = normalize(source.toString().removeRange(deleteAt - 1, deleteAt)) } - val paymentSystem = CardPaymentSystem.resolve(cardNumber) + val paymentSystem = resolvePaymentSystem(cardNumber) - if (cardNumber.length > paymentSystem.range.last) { - cardNumber = cardNumber.substring(0, paymentSystem.range.last) + if (cardNumber.length > prevPayment.range.last && prevPayment != CardPaymentSystem.UNKNOWN) { + cardNumber = cardNumber.substring(0, prevPayment.range.last) } val mask = CardFormatter.resolveCardNumberMask(cardNumber) @@ -44,6 +44,7 @@ internal class CardNumberFormatter : TextWatcher { selfChange = true source.replace(0, source.length, cardNumber) selfChange = false + prevPayment = paymentSystem } private fun applyMask(source: String, mask: String): String = StringBuilder().apply { @@ -57,6 +58,15 @@ internal class CardNumberFormatter : TextWatcher { } }.toString() + private fun resolvePaymentSystem(cardNumber: String): CardPaymentSystem { + val new = CardPaymentSystem.resolve(cardNumber) + return if(new == CardPaymentSystem.UNKNOWN && prevPayment != CardPaymentSystem.UNKNOWN) { + prevPayment + }else{ + new + } + } + companion object { private val REGEX_NON_DIGITS = "\\D".toRegex() diff --git a/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsDeleteViewModelTest.kt b/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsDeleteViewModelTest.kt index e34ccbbd..dbd89a54 100644 --- a/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsDeleteViewModelTest.kt +++ b/ui/src/test/java/ru/tinkoff/acquiring/sdk/redesign/cards/list/CardsDeleteViewModelTest.kt @@ -49,7 +49,7 @@ internal class CardsDeleteViewModelTest { eventCollector.joinWithTimeout() eventCollector.flow.test { - assertByClassName(CardListEvent.RemoveCardProgress, awaitItem()) + assertByClassName(CardListEvent.RemoveCardProgress(mock()), awaitItem()) assertByClassName(CardListEvent.RemoveCardSuccess(card, null), awaitItem()) awaitComplete() } @@ -67,8 +67,8 @@ internal class CardsDeleteViewModelTest { eventCollector.joinWithTimeout() eventCollector.flow.test { - assertByClassName(CardListEvent.RemoveCardProgress, awaitItem()) - assertByClassName(CardListEvent.ShowError, awaitItem()) + assertByClassName(CardListEvent.RemoveCardProgress(mock()), awaitItem()) + assertByClassName(CardListEvent.ShowCardDeleteError(mock()), awaitItem()) awaitComplete() } } From d0dac10800c37f74e44308f2f3eda8fe554790fe Mon Sep 17 00:00:00 2001 From: jqwout Date: Fri, 3 Feb 2023 21:14:23 +0300 Subject: [PATCH 037/126] MC-8137 card scanner --- .../sdk/requests/RemoveCardRequest.kt | 4 +- .../carddatainput/CardDataInputFragment.kt | 16 ++---- .../payment/ui/PaymentByCardActivity.kt | 11 +++- .../sdk/smartfield/BaubleClearOrScanButton.kt | 51 +++++++++++++++++++ .../acquiring/sdk/utils/FragmentExt.kt | 12 ++++- .../main/res/drawable/acq_ic_card_frame.xml | 41 +++++++++++++++ 6 files changed, 116 insertions(+), 19 deletions(-) create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/smartfield/BaubleClearOrScanButton.kt create mode 100644 ui/src/main/res/drawable/acq_ic_card_frame.xml diff --git a/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/RemoveCardRequest.kt b/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/RemoveCardRequest.kt index 3b41361b..334318b1 100644 --- a/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/RemoveCardRequest.kt +++ b/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/RemoveCardRequest.kt @@ -17,7 +17,6 @@ package ru.tinkoff.acquiring.sdk.requests import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf import ru.tinkoff.acquiring.sdk.network.AcquiringApi.REMOVE_CARD_METHOD import ru.tinkoff.acquiring.sdk.responses.RemoveCardResponse import ru.tinkoff.acquiring.sdk.utils.RequestResult @@ -64,7 +63,6 @@ class RemoveCardRequest : AcquiringRequest(REMOVE_CARD_METHO * Реактивный вызов метода API */ fun executeFlow(): Flow> { - return flowOf(RequestResult.Failure(Exception())) - // return super.performRequestFlow(this, RemoveCardResponse::class.java) + return super.performRequestFlow(this, RemoveCardResponse::class.java) } } \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt index 4b05057c..1faaa33b 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/common/carddatainput/CardDataInputFragment.kt @@ -1,7 +1,6 @@ package ru.tinkoff.acquiring.sdk.redesign.common.carddatainput import android.app.Activity -import android.content.Context import android.content.Intent import android.os.Bundle import android.text.method.PasswordTransformationMethod @@ -9,6 +8,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast +import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import kotlinx.android.synthetic.main.acq_fragment_card_data_input.* import ru.tinkoff.acquiring.sdk.R @@ -17,6 +17,7 @@ import ru.tinkoff.acquiring.sdk.cardscanners.CardScanner import ru.tinkoff.acquiring.sdk.smartfield.AcqTextFieldView import ru.tinkoff.acquiring.sdk.smartfield.BaubleCardLogo import ru.tinkoff.acquiring.sdk.smartfield.BaubleClearButton +import ru.tinkoff.acquiring.sdk.smartfield.BaubleClearOrScanButton import ru.tinkoff.acquiring.sdk.ui.customview.editcard.CardPaymentSystem import ru.tinkoff.acquiring.sdk.ui.customview.editcard.CardPaymentSystem.MASTER_CARD import ru.tinkoff.acquiring.sdk.ui.customview.editcard.CardPaymentSystem.VISA @@ -50,7 +51,7 @@ internal class CardDataInputFragment : Fragment() { with(card_number_input) { BaubleCardLogo().attach(this) - BaubleClearButton().attach(this) + BaubleClearOrScanButton().attach(this, cardScanner) val cardNumberFormatter = CardNumberFormatter().also { editText.addTextChangedListener(it) } @@ -69,7 +70,7 @@ internal class CardDataInputFragment : Fragment() { if (cardNumber.length in paymentSystem.range) { if (!CardValidator.validateCardNumber(cardNumber)) { errorHighlighted = true - } else if (cardNumberFormatter.isSingleInsert || shouldAutoSwitchFromCardNumber(cardNumber, paymentSystem)) { + } else if (shouldAutoSwitchFromCardNumber(cardNumber, paymentSystem)) { expiry_date_input.requestViewFocus() } } @@ -133,11 +134,6 @@ internal class CardDataInputFragment : Fragment() { onDataChanged() } - override fun onAttach(context: Context) { - super.onAttach(context) - cardScanner = CardScanner(context) - } - override fun onSaveInstanceState(outState: Bundle) { with(outState) { putString(SAVE_CARD_NUMBER, cardNumber) @@ -165,10 +161,6 @@ internal class CardDataInputFragment : Fragment() { fun setupScanner(cameraCardScanner: CameraCardScanner?) { cardScanner = CardScanner(requireContext()).apply { this.cameraCardScanner = cameraCardScanner - - if (cardScanAvailable) { - // add scan button - } } } diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardActivity.kt index b2a91d89..44d8541d 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardActivity.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/redesign/payment/ui/PaymentByCardActivity.kt @@ -12,8 +12,6 @@ import kotlinx.coroutines.launch import ru.tinkoff.acquiring.sdk.R import ru.tinkoff.acquiring.sdk.TinkoffAcquiring import ru.tinkoff.acquiring.sdk.models.options.screen.PaymentOptions -import ru.tinkoff.acquiring.sdk.models.paysources.CardData -import ru.tinkoff.acquiring.sdk.models.result.AsdkResult import ru.tinkoff.acquiring.sdk.models.result.PaymentResult import ru.tinkoff.acquiring.sdk.payment.PaymentByCardState import ru.tinkoff.acquiring.sdk.redesign.common.carddatainput.CardDataInputFragment @@ -23,6 +21,8 @@ import ru.tinkoff.acquiring.sdk.redesign.dialog.createPaymentSheetWrapper import ru.tinkoff.acquiring.sdk.threeds.ThreeDsHelper import ru.tinkoff.acquiring.sdk.ui.activities.TransparentActivity import ru.tinkoff.acquiring.sdk.ui.customview.LoaderButton +import ru.tinkoff.acquiring.sdk.utils.getOptions +import ru.tinkoff.acquiring.sdk.utils.lazyUnsafe import ru.tinkoff.acquiring.sdk.utils.lazyView import ru.tinkoff.acquiring.sdk.utils.toBundle @@ -43,11 +43,18 @@ internal class PaymentByCardActivity : AppCompatActivity(), private var onPaymentInternal: OnPaymentSheetCloseListener? = null + private val paymentOptions : PaymentOptions by lazyUnsafe { + intent.getOptions() + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.acq_payment_by_card_new_activity) initToolbar() + cardDataInput.setupScanner(paymentOptions.features.cameraCardScanner) + cardDataInput.validateNotExpired = paymentOptions.features.validateExpiryDate + lifecycleScope.launchWhenCreated { buttonState() } lifecycleScope.launch { processState() } payButton.setOnClickListener { diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/smartfield/BaubleClearOrScanButton.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/smartfield/BaubleClearOrScanButton.kt new file mode 100644 index 00000000..c0f67955 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/smartfield/BaubleClearOrScanButton.kt @@ -0,0 +1,51 @@ +package ru.tinkoff.acquiring.sdk.smartfield + +import android.content.res.ColorStateList +import android.view.ViewGroup +import android.widget.ImageView +import androidx.core.content.res.ResourcesCompat +import androidx.core.view.isVisible +import ru.tinkoff.acquiring.sdk.R +import ru.tinkoff.acquiring.sdk.cardscanners.CardScanner +import ru.tinkoff.acquiring.sdk.utils.SimpleTextWatcher +import ru.tinkoff.acquiring.sdk.utils.dpToPx + +internal class BaubleClearOrScanButton { + + private lateinit var textFieldView: AcqTextFieldView + private lateinit var clear: ImageView + private lateinit var scan: ImageView + private var cardScanner: CardScanner? = null + + fun attach(textFieldView: AcqTextFieldView, scanner: CardScanner?) { + this.textFieldView = textFieldView + this.cardScanner = scanner + + val context = textFieldView.context + clear = ImageView(context).apply { + setImageResource(R.drawable.acq_ic_clear) + imageTintList = ColorStateList.valueOf(ResourcesCompat.getColor( + context.resources, R.color.acq_colorTextTertiary, context.theme)) + layoutParams = ViewGroup.LayoutParams(context.dpToPx(16), context.dpToPx(16)) + } + clear.setOnClickListener { textFieldView.text = "" } + textFieldView.addRightBauble(clear) + + scan = ImageView(context).apply { + setImageResource(R.drawable.acq_ic_card_frame) + layoutParams = ViewGroup.LayoutParams(context.dpToPx(16), context.dpToPx(16)) + } + scan.setOnClickListener { scanner?.scanCard() } + textFieldView.addRightBauble(scan) + + textFieldView.addViewFocusChangeListener { update() } + textFieldView.editText.addTextChangedListener(SimpleTextWatcher.after { update() }) + + update() + } + + private fun update() { + clear.isVisible = textFieldView.isEnabled && textFieldView.text.isNullOrBlank().not() + scan.isVisible = textFieldView.isEnabled && cardScanner?.cardScanAvailable == true && textFieldView.text.isNullOrBlank() + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/FragmentExt.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/FragmentExt.kt index f5c5643d..542b78f0 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/FragmentExt.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/utils/FragmentExt.kt @@ -3,12 +3,20 @@ package ru.tinkoff.acquiring.sdk.utils import androidx.fragment.app.Fragment -import ru.tinkoff.acquiring.sdk.redesign.common.carddatainput.CardDataInputFragment +import kotlin.properties.ReadOnlyProperty /** * Created by i.golovachev */ -fun Fragment.getParent() : T? { +fun Fragment.getParent(): T? { val parent = (parentFragment as? T) ?: (activity as? T) return parent +} + +fun Fragment.serializableArg() = ReadOnlyProperty { thisRef, property -> + thisRef.arguments?.getSerializable(property.name) as T +} + +fun Fragment.parcelableArg() = ReadOnlyProperty { thisRef, property -> + thisRef.arguments?.getParcelable(property.name) } \ No newline at end of file diff --git a/ui/src/main/res/drawable/acq_ic_card_frame.xml b/ui/src/main/res/drawable/acq_ic_card_frame.xml new file mode 100644 index 00000000..373f3459 --- /dev/null +++ b/ui/src/main/res/drawable/acq_ic_card_frame.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + From d67a3b1647856d76aa9db63f905c707096d56692 Mon Sep 17 00:00:00 2001 From: jqwout Date: Fri, 3 Feb 2023 21:14:23 +0300 Subject: [PATCH 038/126] MC-8137 card scanner --- .../cardio/CameraCardIOScannerContract.kt | 73 +++++++++++ .../sdk/requests/RemoveCardRequest.kt | 4 +- .../sample/camera/DemoCameraScanActivity.kt | 33 ++++- .../acquiring/sample/ui/MainActivity.kt | 2 + .../acquiring/sample/ui/PayableActivity.kt | 1 + .../utils/PaymentNotificationManager.kt | 1 + .../sample/utils/SettingsSdkManager.kt | 10 ++ .../sdk/cardscanners/CameraCardScanner.kt | 1 + .../acquiring/sdk/cardscanners/CardScanner.kt | 8 +- .../delegate/CardScannerNewApi.kt | 117 ++++++++++++++++++ .../cardscanners/delegate/CardScannerTypes.kt | 8 ++ .../delegate/CardScannerWrapper.kt | 76 ++++++++++++ .../delegate/nfc/NfcCardScannerDelegate.kt | 39 ++++++ .../cardscanners/ui/AsdkNfcScanActivity.kt | 9 +- .../sdk/models/options/FeaturesOptions.kt | 9 ++ .../carddatainput/CardDataInputFragment.kt | 76 ++++++------ .../payment/ui/PaymentByCardActivity.kt | 38 ++---- .../sdk/smartfield/BaubleClearOrScanButton.kt | 54 ++++++++ .../sdk/ui/fragments/AttachCardFragment.kt | 2 +- .../acquiring/sdk/utils/FragmentExt.kt | 12 +- .../main/res/drawable/acq_ic_card_frame.xml | 41 ++++++ ui/src/main/res/layout/acq_activity_nfc.xml | 2 +- ui/src/main/res/values-ru/strings.xml | 5 + ui/src/main/res/values/strings.xml | 5 + 24 files changed, 538 insertions(+), 88 deletions(-) create mode 100644 cardio/src/main/java/ru/tinkoff/cardio/CameraCardIOScannerContract.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/delegate/CardScannerNewApi.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/delegate/CardScannerTypes.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/delegate/CardScannerWrapper.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/delegate/nfc/NfcCardScannerDelegate.kt create mode 100644 ui/src/main/java/ru/tinkoff/acquiring/sdk/smartfield/BaubleClearOrScanButton.kt create mode 100644 ui/src/main/res/drawable/acq_ic_card_frame.xml diff --git a/cardio/src/main/java/ru/tinkoff/cardio/CameraCardIOScannerContract.kt b/cardio/src/main/java/ru/tinkoff/cardio/CameraCardIOScannerContract.kt new file mode 100644 index 00000000..91a5b44b --- /dev/null +++ b/cardio/src/main/java/ru/tinkoff/cardio/CameraCardIOScannerContract.kt @@ -0,0 +1,73 @@ +/* + * Copyright © 2020 Tinkoff Bank + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ru.tinkoff.cardio + +import android.app.Activity.RESULT_CANCELED +import android.app.Activity.RESULT_OK +import android.content.Context +import android.content.Intent +import io.card.payment.CardIOActivity +import io.card.payment.CreditCard +import ru.tinkoff.acquiring.sdk.cardscanners.delegate.CardScannerContract +import ru.tinkoff.acquiring.sdk.cardscanners.delegate.ScannedCardResult +import ru.tinkoff.acquiring.sdk.cardscanners.models.AsdkScannedCardData +import ru.tinkoff.acquiring.sdk.cardscanners.models.ScannedCardData +import java.util.* + +/** + * @author Mariya Chernyadieva + */ +object CameraCardIOScannerContract : CardScannerContract() { + + override fun createIntent(context: Context, input: Unit): Intent { + return createIntent(context) + } + + override fun parseResult(resultCode: Int, intent: Intent?): ScannedCardResult { + return when (resultCode) { + RESULT_OK -> ScannedCardResult.Success(parseIntentData(intent!!)) + RESULT_CANCELED -> ScannedCardResult.Cancel + else -> ScannedCardResult.Failure(null) + } + } + + private fun parseIntentData(data: Intent): ScannedCardData { + val cardNumber: String + var expireDate = "" + val cardholderName = "" + + val scanResult = data.getParcelableExtra(CardIOActivity.EXTRA_SCAN_RESULT) + cardNumber = scanResult!!.formattedCardNumber + if (scanResult.expiryMonth != 0 && scanResult.expiryYear != 0) { + val locale = Locale.getDefault() + val expiryYear = scanResult.expiryYear % 100 + expireDate = String.format(locale, "%02d%02d", scanResult.expiryMonth, expiryYear) + } + + return AsdkScannedCardData(cardNumber, expireDate, cardholderName) + } + + private fun createIntent(context: Context): Intent { + val scanIntent = Intent(context, CardIOActivity::class.java) + return scanIntent.apply { + putExtra(CardIOActivity.EXTRA_REQUIRE_EXPIRY, true) + putExtra(CardIOActivity.EXTRA_REQUIRE_CVV, false) + putExtra(CardIOActivity.EXTRA_REQUIRE_POSTAL_CODE, false) + putExtra(CardIOActivity.EXTRA_SUPPRESS_CONFIRMATION, true) + } + } +} \ No newline at end of file diff --git a/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/RemoveCardRequest.kt b/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/RemoveCardRequest.kt index 3b41361b..334318b1 100644 --- a/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/RemoveCardRequest.kt +++ b/core/src/main/java/ru/tinkoff/acquiring/sdk/requests/RemoveCardRequest.kt @@ -17,7 +17,6 @@ package ru.tinkoff.acquiring.sdk.requests import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf import ru.tinkoff.acquiring.sdk.network.AcquiringApi.REMOVE_CARD_METHOD import ru.tinkoff.acquiring.sdk.responses.RemoveCardResponse import ru.tinkoff.acquiring.sdk.utils.RequestResult @@ -64,7 +63,6 @@ class RemoveCardRequest : AcquiringRequest(REMOVE_CARD_METHO * Реактивный вызов метода API */ fun executeFlow(): Flow> { - return flowOf(RequestResult.Failure(Exception())) - // return super.performRequestFlow(this, RemoveCardResponse::class.java) + return super.performRequestFlow(this, RemoveCardResponse::class.java) } } \ No newline at end of file diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/camera/DemoCameraScanActivity.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/camera/DemoCameraScanActivity.kt index 90ee4e13..462f7de5 100644 --- a/sample/src/main/java/ru/tinkoff/acquiring/sample/camera/DemoCameraScanActivity.kt +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/camera/DemoCameraScanActivity.kt @@ -17,11 +17,15 @@ package ru.tinkoff.acquiring.sample.camera import android.app.Activity +import android.content.Context import android.content.Intent import android.os.Bundle import android.widget.Button import androidx.appcompat.app.AppCompatActivity import ru.tinkoff.acquiring.sample.R +import ru.tinkoff.acquiring.sdk.cardscanners.delegate.CardScannerContract +import ru.tinkoff.acquiring.sdk.cardscanners.delegate.ScannedCardResult +import ru.tinkoff.acquiring.sdk.cardscanners.models.AsdkScannedCardData import java.util.* /** @@ -52,8 +56,33 @@ class DemoCameraScanActivity : AppCompatActivity() { const val EXTRA_CARD_NUMBER = "card_number" const val EXTRA_EXPIRE_DATE = "expire_date" - private const val EXPIRE_DATE = "11/21" - private val CARD_NUMBERS = arrayOf("5136 9149 2034 4072", "5136 9149 2034 7240", "5203 7375 0075 0535", "5203 7375 0075 3505") + private const val EXPIRE_DATE = "11/28" + private val CARD_NUMBERS = arrayOf( + "5136 9149 2034 4072", + "5136 9149 2034 7240", + "5203 7375 0075 0535", + "5203 7375 0075 3505" + ) private val random = Random() } + + object Contract : CardScannerContract() { + override fun createIntent(context: Context, input: Unit): Intent { + return Intent(context, DemoCameraScanActivity::class.java) + } + + override fun parseResult(resultCode: Int, intent: Intent?): ScannedCardResult { + return when (resultCode) { + RESULT_CANCELED -> ScannedCardResult.Cancel + RESULT_OK -> ScannedCardResult.Success( + AsdkScannedCardData( + cardNumber = intent?.getStringExtra(EXTRA_CARD_NUMBER).orEmpty(), + expireDate = intent?.getStringExtra(EXTRA_EXPIRE_DATE).orEmpty(), + "" + ) + ) + else -> ScannedCardResult.Failure(null) + } + } + } } \ No newline at end of file diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt index a0bee731..9bd199f1 100644 --- a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/MainActivity.kt @@ -223,6 +223,7 @@ class MainActivity : AppCompatActivity(), BooksListAdapter.BookDetailsClickListe useSecureKeyboard = settings.isCustomKeyboardEnabled validateExpiryDate = settings.validateExpiryDate cameraCardScanner = settings.cameraScanner + cameraCardScannerContract = settings.cameraScannerContract darkThemeMode = settings.resolveDarkThemeMode() theme = settings.resolveAttachCardStyle() } @@ -255,6 +256,7 @@ class MainActivity : AppCompatActivity(), BooksListAdapter.BookDetailsClickListe useSecureKeyboard = settings.isCustomKeyboardEnabled validateExpiryDate = settings.validateExpiryDate cameraCardScanner = settings.cameraScanner + cameraCardScannerContract = settings.cameraScannerContract darkThemeMode = settings.resolveDarkThemeMode() theme = settings.resolveAttachCardStyle() userCanSelectCard = true diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt index aee8c081..7c334122 100644 --- a/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/ui/PayableActivity.kt @@ -315,6 +315,7 @@ open class PayableActivity : AppCompatActivity() { useSecureKeyboard = settings.isCustomKeyboardEnabled validateExpiryDate = settings.validateExpiryDate cameraCardScanner = settings.cameraScanner + cameraCardScannerContract = settings.cameraScannerContract fpsEnabled = settings.isFpsEnabled tinkoffPayEnabled = settings.isTinkoffPayEnabled darkThemeMode = settings.resolveDarkThemeMode() diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/PaymentNotificationManager.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/PaymentNotificationManager.kt index 72e1f5de..67506b7c 100644 --- a/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/PaymentNotificationManager.kt +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/PaymentNotificationManager.kt @@ -159,6 +159,7 @@ object PaymentNotificationManager { useSecureKeyboard = settings.isCustomKeyboardEnabled validateExpiryDate = settings.validateExpiryDate cameraCardScanner = settings.cameraScanner + cameraCardScannerContract = settings.cameraScannerContract fpsEnabled = settings.isFpsEnabled tinkoffPayEnabled = settings.isTinkoffPayEnabled darkThemeMode = settings.resolveDarkThemeMode() diff --git a/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/SettingsSdkManager.kt b/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/SettingsSdkManager.kt index 5257ffd1..3f4eadc9 100644 --- a/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/SettingsSdkManager.kt +++ b/sample/src/main/java/ru/tinkoff/acquiring/sample/utils/SettingsSdkManager.kt @@ -21,10 +21,13 @@ import android.content.SharedPreferences import androidx.annotation.StyleRes import androidx.preference.PreferenceManager import ru.tinkoff.acquiring.sample.R +import ru.tinkoff.acquiring.sample.camera.DemoCameraScanActivity import ru.tinkoff.acquiring.sample.camera.DemoCameraScanner import ru.tinkoff.acquiring.sdk.cardscanners.CameraCardScanner +import ru.tinkoff.acquiring.sdk.cardscanners.delegate.CardScannerContract import ru.tinkoff.acquiring.sdk.models.DarkThemeMode import ru.tinkoff.cardio.CameraCardIOScanner +import ru.tinkoff.cardio.CameraCardIOScannerContract /** * @author Mariya Chernyadieva @@ -75,6 +78,13 @@ class SettingsSdkManager(private val context: Context) { } else DemoCameraScanner() } + val cameraScannerContract: CardScannerContract + get() { + val cardIOCameraScan = context.getString(R.string.acq_sp_camera_type_card_io) + val cameraScan = preferences.getString(context.getString(R.string.acq_sp_camera_type_id), cardIOCameraScan) + return if (cardIOCameraScan == cameraScan) CameraCardIOScannerContract else DemoCameraScanActivity.Contract + } + val handleCardListErrorInSdk: Boolean get() = preferences.getBoolean(context.getString(R.string.acq_sp_handle_cards_error), true) diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/CameraCardScanner.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/CameraCardScanner.kt index 1fe73d9c..8b422a27 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/CameraCardScanner.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/CameraCardScanner.kt @@ -31,6 +31,7 @@ interface CameraCardScanner : Serializable { /** * Запуск экрана сканирования карты */ + @Deprecated("use getActivityForScanningIntent and activity result api") fun startActivityForScanning(context: Context, requestCode: Int) /** diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/CardScanner.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/CardScanner.kt index da41e9e7..941962f4 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/CardScanner.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/CardScanner.kt @@ -63,7 +63,8 @@ internal class CardScanner(private val context: Context) { private fun openScanTypeDialog() { val localization = AsdkLocalization.resources - val itemsArray = arrayOf(localization.payDialogCardScanCamera, localization.payDialogCardScanNfc) + val itemsArray = + arrayOf(localization.payDialogCardScanCamera, localization.payDialogCardScanNfc) AlertDialog.Builder(context).apply { setItems(itemsArray) { dialog, item -> when (item) { @@ -75,9 +76,8 @@ internal class CardScanner(private val context: Context) { }.show() } - private fun startNfcScan() { - val cardFromNfcIntent = Intent(context, AsdkNfcScanActivity::class.java) - (context as Activity).startActivityForResult(cardFromNfcIntent, REQUEST_CARD_NFC) + private fun startNfcScan(): Intent { + return Intent(context, AsdkNfcScanActivity::class.java) } private fun startCameraScan() { diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/delegate/CardScannerNewApi.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/delegate/CardScannerNewApi.kt new file mode 100644 index 00000000..959acb69 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/delegate/CardScannerNewApi.kt @@ -0,0 +1,117 @@ +package ru.tinkoff.acquiring.sdk.cardscanners.delegate + +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.contract.ActivityResultContract +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import ru.tinkoff.acquiring.sdk.cardscanners.models.ScannedCardData + +/** + * Created by i.golovachev + */ + +/** + * Интерфейс для инкапсуляции логики работы какого - либо метода сканирования + */ +interface CardScannerDelegate { + + /** + * проверка доступности метода сканирования + */ + val isEnabled: Boolean + + /** + * запуск процесса сканирования + */ + fun start() +} + +fun CardScannerDelegate?.isEnabled() = this?.isEnabled ?: false + +/** + * Интерфейс для инкапсуляции логики работы какого - либо метода сканирования + */ +typealias AsdkCardScanResultCallback = ActivityResultCallback + + +/** + * Множество , описывающее результат сканирования + */ +sealed class ScannedCardResult { + class Success(val data: ScannedCardData) : ScannedCardResult() + object Cancel : ScannedCardResult() + class Failure(val throwable: Throwable?) : ScannedCardResult() +} + +/** + * Контракт, для запуска экрана сканирования карты, и считывания результата + */ +abstract class CardScannerContract : + ActivityResultContract(), + java.io.Serializable + +/** + * Базовый класс, объеденяющий запуск экрана сканирования и обработку результата, + * основанный на new result api.Позволяет разделить обьявления и обработку результата в разных + * местах кода. + * + * activity - Требует activity для регистрации ActivityResultLauncher + * contract - Контракт открытия экрана и получения результата CardScannerContract + * callback - Обратный вызов, для использования полученных данных + * isEnabledChecker - метод, для проверки доступности метода + * scanKey - ключ, для регистрации коллбека для получения результата , используется NFC или Camera + * наследует CardScannerDelegate. + */ +open class AsdkCardScannerDelegate( + private val activityResultRegistry: ActivityResultRegistry, + private val activityLifecycle: Lifecycle, + private val activityScanCardContract: CardScannerContract, + private val scannedDataCallback: AsdkCardScanResultCallback, + private val scanType: CardScannerTypes, + private val isEnabledChecker: () -> Boolean, +) : CardScannerDelegate { + + constructor( + activity: ComponentActivity, + contract: CardScannerContract, + scanned: AsdkCardScanResultCallback, + scanType: CardScannerTypes, + isEnabledChecker: () -> Boolean, + ) : this( + activity.activityResultRegistry, + activity.lifecycle, + contract, + scanned, + scanType, + isEnabledChecker + ) + + private lateinit var launcher: ActivityResultLauncher + + init { + activityLifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onCreate(owner: LifecycleOwner) { + launcher = activityResultRegistry.register( + scanType.name, + activityScanCardContract, + scannedDataCallback + ) + } + }) + } + + override val isEnabled: Boolean + get() = isEnabledChecker() + + override fun start() { + launcher.launch(Unit) + } + + companion object { + const val SCAN_KEY = "extra_scan_key" + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/delegate/CardScannerTypes.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/delegate/CardScannerTypes.kt new file mode 100644 index 00000000..aeb7bb82 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/delegate/CardScannerTypes.kt @@ -0,0 +1,8 @@ +package ru.tinkoff.acquiring.sdk.cardscanners.delegate + +/** + * Created by i.golovachev + */ +enum class CardScannerTypes { + NFC, CAMERA +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/delegate/CardScannerWrapper.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/delegate/CardScannerWrapper.kt new file mode 100644 index 00000000..52aae968 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/delegate/CardScannerWrapper.kt @@ -0,0 +1,76 @@ +package ru.tinkoff.acquiring.sdk.cardscanners.delegate + +import android.app.AlertDialog +import android.content.pm.PackageManager +import androidx.activity.ComponentActivity +import ru.tinkoff.acquiring.sdk.R +import ru.tinkoff.acquiring.sdk.cardscanners.delegate.nfc.NfcCardScannerDelegate + +/** + * Created by i.golovachev + * + * Обертка , содержащая 2 спобоска сканирования: + * Nfc - реализован по дефолту, нет возможности поменять + * Camera - по дефолту недоступен, есть возможность реализовать на стороне приложения. + */ +internal class CardScannerWrapper( + private val activity: ComponentActivity, + private val callback: AsdkCardScanResultCallback +) : CardScannerDelegate { + + /** + * Метод, для установки кастомного контракта сканирования по камере + */ + var cameraCardScannerContract: CardScannerContract? = null + set(value) { + field = value + cameraCardScanner = if (field == null) { + null + } else { + AsdkCardScannerDelegate( + activity, field!!, callback, scanType = CardScannerTypes.CAMERA + ) { activity.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) && field != null } + } + } + + private var nfcCardScanner: AsdkCardScannerDelegate = NfcCardScannerDelegate(activity, callback) + private var cameraCardScanner: CardScannerDelegate? = null + + override fun start() { + when { + isNfcEnable() && isCameraEnable() -> openScanTypeDialog() + isNfcEnable() -> nfcCardScanner.start() + isCameraEnable() -> cameraCardScanner?.start() + } + } + + override val isEnabled: Boolean + get() = isCameraEnable() || isNfcEnable() + + private fun isCameraEnable() = cameraCardScanner.isEnabled() + + private fun isNfcEnable() = nfcCardScanner.isEnabled + + private fun openScanTypeDialog() { + val itemsArray = arrayOf(R.string.acq_scan_by_camera, R.string.acq_scan_by_nfc) + .map { activity.getString(it) }.toTypedArray() + + AlertDialog.Builder(activity).apply { + setItems(itemsArray) { dialog, item -> + when (item) { + CAMERA_ITEM -> cameraCardScanner?.start() + NFC_ITEM -> nfcCardScanner.start() + else -> throw IllegalStateException("unknown item for: $item") + } + dialog.dismiss() + } + }.show() + } + + companion object { + + private const val CAMERA_ITEM = 0 + private const val NFC_ITEM = 1 + } + +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/delegate/nfc/NfcCardScannerDelegate.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/delegate/nfc/NfcCardScannerDelegate.kt new file mode 100644 index 00000000..6f3d3f48 --- /dev/null +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/delegate/nfc/NfcCardScannerDelegate.kt @@ -0,0 +1,39 @@ +package ru.tinkoff.acquiring.sdk.cardscanners.delegate.nfc + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.activity.ComponentActivity +import ru.tinkoff.acquiring.sdk.cardscanners.delegate.* +import ru.tinkoff.acquiring.sdk.cardscanners.models.ScannedCardData +import ru.tinkoff.acquiring.sdk.cardscanners.ui.AsdkNfcScanActivity +import ru.tinkoff.acquiring.sdk.cardscanners.ui.AsdkNfcScanActivity.Companion.EXTRA_CARD + +/** + * Дефолтная реализация сканированния карты по НФС + */ +internal class NfcCardScannerDelegate( + activity: ComponentActivity, + callback: AsdkCardScanResultCallback +) : AsdkCardScannerDelegate( + activity = activity, + contract = NfcCardScannerContract, + callback, + CardScannerTypes.NFC, + isEnabledChecker = { activity.packageManager.hasSystemFeature(PackageManager.FEATURE_NFC) } +) + +object NfcCardScannerContract : CardScannerContract() { + override fun createIntent(context: Context, input: Unit) = + Intent(context, AsdkNfcScanActivity::class.java) + + override fun parseResult(resultCode: Int, intent: Intent?): ScannedCardResult { + return when (resultCode) { + Activity.RESULT_OK -> ScannedCardResult.Success(intent!!.getSerializableExtra(EXTRA_CARD) as ScannedCardData) + Activity.RESULT_CANCELED -> ScannedCardResult.Cancel + AsdkNfcScanActivity.RESULT_ERROR -> ScannedCardResult.Failure(null) + else -> throw java.lang.IllegalStateException("unknown code: $resultCode") + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/ui/AsdkNfcScanActivity.kt b/ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/ui/AsdkNfcScanActivity.kt index 6814ef81..68b2cdd3 100644 --- a/ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/ui/AsdkNfcScanActivity.kt +++ b/ui/src/main/java/ru/tinkoff/acquiring/sdk/cardscanners/ui/AsdkNfcScanActivity.kt @@ -58,14 +58,13 @@ internal class AsdkNfcScanActivity : AppCompatActivity() { override fun onNfcDisabled() = showDialog() }) - window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN setupTranslucentStatusBar() val nfsDescription = findViewById(R.id.acq_nfc_tv_description) - nfsDescription.text = AsdkLocalization.resources.nfcDescription + nfsDescription.setText(R.string.acq_scan_by_nfc_description) val closeBtn = findViewById