diff --git a/code-confirm/.gitignore b/code-confirm/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/code-confirm/.gitignore @@ -0,0 +1 @@ +/build diff --git a/code-confirm/build.gradle b/code-confirm/build.gradle new file mode 100644 index 0000000..d135f88 --- /dev/null +++ b/code-confirm/build.gradle @@ -0,0 +1,24 @@ +apply from: "../android-configs/lib-config.gradle" + +dependencies { + implementation project(":mvi-arch") + implementation project(":lifecycle") + + implementation "androidx.lifecycle:lifecycle-extensions" + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx") + + def lifecycleVersion = "2.2.0" + + constraints { + implementation("androidx.lifecycle:lifecycle-extensions") { + version { + require '2.1.0' + } + } + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx") { + version { + require(lifecycleVersion) + } + } + } +} diff --git a/code-confirm/src/main/AndroidManifest.xml b/code-confirm/src/main/AndroidManifest.xml new file mode 100644 index 0000000..749fd3b --- /dev/null +++ b/code-confirm/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/code-confirm/src/main/java/ru/touchin/code_confirm/BaseCodeConfirmAction.kt b/code-confirm/src/main/java/ru/touchin/code_confirm/BaseCodeConfirmAction.kt new file mode 100644 index 0000000..745bef2 --- /dev/null +++ b/code-confirm/src/main/java/ru/touchin/code_confirm/BaseCodeConfirmAction.kt @@ -0,0 +1,22 @@ +package ru.touchin.code_confirm + +/** + * [CodeConfirmAction] is interface for the action that will call + * the confirmation request with entered code + */ +interface CodeConfirmAction + +/** + * [UpdatedCodeInputAction] is interface for the action, that should be called + * after each update of codeInput + * @param code Updated string with code from codeInput + */ +interface UpdatedCodeInputAction { + val code: String? +} + +/** + * [GetRefreshCodeAction] is interface for the action that will call + * the request of a repeat code after it's expired + */ +interface GetRefreshCodeAction diff --git a/code-confirm/src/main/java/ru/touchin/code_confirm/BaseCodeConfirmState.kt b/code-confirm/src/main/java/ru/touchin/code_confirm/BaseCodeConfirmState.kt new file mode 100644 index 0000000..3a916d8 --- /dev/null +++ b/code-confirm/src/main/java/ru/touchin/code_confirm/BaseCodeConfirmState.kt @@ -0,0 +1,18 @@ +package ru.touchin.code_confirm + +import ru.touchin.roboswag.mvi_arch.marker.ViewState + +abstract class BaseCodeConfirmState( + open var codeLifetime: String, + open var isLoadingState: Boolean, + open var isWrongCode: Boolean, + open var isExpired: Boolean, + open var isRefreshCodeLoading: Boolean = false, + open var needSendCode: Boolean = true +) : ViewState { + + val canRequestNewCode: Boolean + get() = isExpired && !isRefreshCodeLoading + + abstract fun copyWith(updateBlock: T.() -> Unit): T +} diff --git a/code-confirm/src/main/java/ru/touchin/code_confirm/BaseCodeConfirmViewModel.kt b/code-confirm/src/main/java/ru/touchin/code_confirm/BaseCodeConfirmViewModel.kt new file mode 100644 index 0000000..1d78e32 --- /dev/null +++ b/code-confirm/src/main/java/ru/touchin/code_confirm/BaseCodeConfirmViewModel.kt @@ -0,0 +1,134 @@ +package ru.touchin.code_confirm + +import android.os.CountDownTimer +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import ru.touchin.code_confirm.LifeTimer.Companion.getFormattedCodeLifetimeString +import ru.touchin.lifecycle.extensions.toImmutable +import ru.touchin.lifecycle.livedata.SingleLiveEvent +import ru.touchin.roboswag.mvi_arch.core.MviViewModel +import ru.touchin.roboswag.mvi_arch.marker.ViewAction + +@SuppressWarnings("detekt.TooGenericExceptionCaught") +abstract class BaseCodeConfirmViewModel( + initialState: State, + savedStateHandle: SavedStateHandle +) : MviViewModel(initialState, savedStateHandle) { + + /** [requireCodeId] uses for auto-filling */ + protected open var requireCodeId: String? = null + + private var timer: CountDownTimer? = null + + private var currentConfirmationCode: String? = null + + private val _updateCodeEvent = SingleLiveEvent() + val updateCodeEvent = _updateCodeEvent.toImmutable() + + init { + _state.value = currentState.copyWith { + codeLifetime = getFormattedCodeLifetimeString(getTimerDuration().toLong()) + } + + startTimer(seconds = getTimerDuration()) + } + + protected abstract fun getTimerDuration(): Int + protected abstract suspend fun requestNewCode(): BaseCodeResponse + protected abstract suspend fun requestCodeConfirmation(code: String) + + protected open fun onRefreshCodeRequestError(e: Throwable) {} + protected open fun onCodeConfirmationError(e: Throwable) {} + protected open fun onSuccessCodeConfirmation(code: String) {} + + override fun dispatchAction(action: Action) { + super.dispatchAction(action) + when (action) { + is CodeConfirmAction -> { + if (currentState.needSendCode) confirmCode() + } + is GetRefreshCodeAction -> { + getRefreshCode() + } + is UpdatedCodeInputAction -> { + val confirmationCodeChanged = currentConfirmationCode != action.code + + _state.value = currentState.copyWith { + isWrongCode = isWrongCode && !confirmationCodeChanged + needSendCode = confirmationCodeChanged + } + currentConfirmationCode = action.code + } + } + } + + protected open fun startTimer(seconds: Int) { + timer?.cancel() + timer = LifeTimer( + seconds = seconds, + tickAction = { millis -> + _state.value = currentState.copyWith { + codeLifetime = getFormattedCodeLifetimeString(millis) + isExpired = false + } + }, + finishAction = { + _state.value = currentState.copyWith { + isExpired = true + } + } + ) + timer?.start() + } + + protected open fun getRefreshCode() { + viewModelScope.launch { + try { + _state.value = currentState.copyWith { + isRefreshCodeLoading = true + isWrongCode = false + } + val confirmationData = requestNewCode() + requireCodeId = confirmationData.codeId + + startTimer(seconds = confirmationData.codeLifetime) + } catch (throwable: Throwable) { + _state.value = currentState.copyWith { needSendCode = false } + onRefreshCodeRequestError(throwable) + } finally { + _state.value = currentState.copyWith { isRefreshCodeLoading = false } + } + } + } + + protected open fun confirmCode() { + currentConfirmationCode?.let { code -> + _state.value = currentState.copyWith { isLoadingState = true } + viewModelScope.launch { + try { + requestCodeConfirmation(code) + onSuccessCodeConfirmation(code) + } catch (throwable: Throwable) { + _state.value = currentState.copyWith { needSendCode = false } + onCodeConfirmationError(throwable) + } finally { + _state.value = currentState.copyWith { isLoadingState = false } + } + } + } + } + + protected open fun autofillCode(code: String, codeId: String? = null) { + if (codeId == requireCodeId) { + _updateCodeEvent.setValue(code) + } + } + + override fun onCleared() { + super.onCleared() + timer?.cancel() + } + +} diff --git a/code-confirm/src/main/java/ru/touchin/code_confirm/BaseCodeResponse.kt b/code-confirm/src/main/java/ru/touchin/code_confirm/BaseCodeResponse.kt new file mode 100644 index 0000000..a300d4e --- /dev/null +++ b/code-confirm/src/main/java/ru/touchin/code_confirm/BaseCodeResponse.kt @@ -0,0 +1,6 @@ +package ru.touchin.code_confirm + +abstract class BaseCodeResponse( + open val codeLifetime: Int, + open val codeId: String? = null +) diff --git a/code-confirm/src/main/java/ru/touchin/code_confirm/LifeTimer.kt b/code-confirm/src/main/java/ru/touchin/code_confirm/LifeTimer.kt new file mode 100644 index 0000000..9478076 --- /dev/null +++ b/code-confirm/src/main/java/ru/touchin/code_confirm/LifeTimer.kt @@ -0,0 +1,35 @@ +package ru.touchin.code_confirm + +import android.os.CountDownTimer +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.Date + +/** [LifeTimer] is extends [CountDownTimer] for countdown in seconds and lifetime text formatting + * @param seconds Lifetime of timer in seconds + * @param tickAction Action will be called on regular interval + * @param finishAction Action will be called on finish */ +class LifeTimer( + seconds: Int, + private val tickAction: (Long) -> Unit, + private val finishAction: () -> Unit +) : CountDownTimer(seconds.toLong() * 1000, 1000) { + + override fun onTick(millisUntilFinished: Long) { + tickAction.invoke(millisUntilFinished / 1000) + } + + override fun onFinish() { + finishAction.invoke() + } + + companion object { + + private val formatter = SimpleDateFormat("mm:ss", Locale.ROOT) + + fun getFormattedCodeLifetimeString(secondsUntilFinished: Long): String = + formatter.format(Date(secondsUntilFinished * 1000L)) + + } + +}