move to roboswag modules (mvi, pagination, cicerone, viewbinding) from test proj
This commit is contained in:
parent
5f39d04f46
commit
d565637407
|
|
@ -4,9 +4,9 @@ Roboswag - библиотека решений, ускоряющих разра
|
|||
|
||||
## Минимальные требования
|
||||
|
||||
* Andoroid Api: 19
|
||||
* Android Api: 21
|
||||
* Kotlin: 1.3.11
|
||||
* Gradle: 3.2.1
|
||||
* Gradle: 4.0.0
|
||||
|
||||
## Основная архитектура
|
||||
За основу архитектуры взят подход от Google - MVVM на основе [Android Architecture Components](https://developer.android.com/jetpack/docs/guide). Данный подход популярен в сообществе Android разработки, позволяет разбивать код на мелкие и независимые части, что ускоряет разработку и последующую поддержку приложения.
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
Encrypted shared preferences
|
||||
============================
|
||||
|
||||
Модуль с реализацией интерфейса `SharedPreferences`, который дает возможность шифровать содержимое.
|
||||
|
||||
### Пример
|
||||
|
||||
Пример получения экземпляра `TouchinSharedPreferences`. При encrypt = false, `TouchinSharedPreferences` абсолютно аналогичны стандартной реализации `SharedPreferences`
|
||||
|
||||
```kotlin
|
||||
val prefs = TouchinSharedPreferences(name = "APPLICATION_DATA_ENCRYPTED", context = context, encrypt = true)
|
||||
```
|
||||
|
||||
Важно помнить, что в одном файле `TouchinSharedPreferences` могут храниться только либо полностью зашифрованные данные, либо полностью незашифрованные. Флаг `isEncryption` должен быть в соответствующем положении
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
apply plugin: 'com.android.library'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
android {
|
||||
compileSdkVersion versions.compileSdk
|
||||
|
||||
defaultConfig {
|
||||
minSdkVersion 21
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
|
||||
implementation "androidx.core:core:$versions.androidx"
|
||||
implementation "androidx.annotation:annotation:$versions.androidx"
|
||||
implementation "androidx.appcompat:appcompat:$versions.appcompat"
|
||||
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
<manifest package="ru.touchin.roboswag.sharedprefs" />
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
package ru.touchin.roboswag
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import ru.touchin.roboswag.PrefsCryptoUtils.Companion.ENCRYPT_BASE64_STRING_LENGTH
|
||||
import ru.touchin.roboswag.PrefsCryptoUtils.Companion.ENCRYPT_BLOCK_SIZE
|
||||
|
||||
class CipherSharedPreferences(name: String, context: Context, val encrypt: Boolean = false) : SharedPreferences {
|
||||
|
||||
private val currentPreferences: SharedPreferences = context.getSharedPreferences(name, Context.MODE_PRIVATE)
|
||||
private val cryptoUtils = PrefsCryptoUtils(context)
|
||||
|
||||
override fun contains(key: String?) = currentPreferences.contains(key)
|
||||
|
||||
override fun getBoolean(key: String?, defaultValue: Boolean) = get(key, defaultValue)
|
||||
|
||||
override fun unregisterOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener: SharedPreferences.OnSharedPreferenceChangeListener?) {
|
||||
currentPreferences.unregisterOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener)
|
||||
}
|
||||
|
||||
override fun getInt(key: String?, defaultValue: Int) = get(key, defaultValue)
|
||||
|
||||
override fun getAll(): MutableMap<String, String> {
|
||||
return if (encrypt) {
|
||||
currentPreferences.all.mapValues { it.value.toString().decrypt() }.toMutableMap()
|
||||
} else {
|
||||
currentPreferences.all.mapValues { it.value.toString() }.toMutableMap()
|
||||
}
|
||||
}
|
||||
|
||||
override fun edit() = TouchinEditor()
|
||||
|
||||
override fun getLong(key: String?, defaultValue: Long) = get(key, defaultValue)
|
||||
|
||||
override fun getFloat(key: String?, defaultValue: Float) = get(key, defaultValue)
|
||||
|
||||
override fun getString(key: String?, defaultValue: String?): String = get(key, defaultValue ?: "")
|
||||
|
||||
override fun getStringSet(key: String?, set: MutableSet<String>?): MutableSet<String>? {
|
||||
return if (encrypt) {
|
||||
val value = currentPreferences.getStringSet(key, set)
|
||||
if (value == set) {
|
||||
set
|
||||
} else {
|
||||
value?.map { it.decrypt() }?.toMutableSet()
|
||||
}
|
||||
} else {
|
||||
currentPreferences.getStringSet(key, set)
|
||||
}
|
||||
}
|
||||
|
||||
override fun registerOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener: SharedPreferences.OnSharedPreferenceChangeListener?) {
|
||||
currentPreferences.registerOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener)
|
||||
}
|
||||
|
||||
private fun <T> get(key: String?, defaultValue: T): T {
|
||||
if (!currentPreferences.contains(key)) return defaultValue
|
||||
val resultValue = currentPreferences.getString(key, "")
|
||||
?.trim()
|
||||
?.chunked(ENCRYPT_BASE64_STRING_LENGTH)
|
||||
?.joinToString(separator = "", transform = { it.decrypt() })
|
||||
|
||||
return when (defaultValue) {
|
||||
is Boolean -> resultValue?.toBoolean() as? T
|
||||
is Long -> resultValue?.toLong() as? T
|
||||
is String -> resultValue as? T
|
||||
is Int -> resultValue?.toInt() as? T
|
||||
is Float -> resultValue?.toFloat() as? T
|
||||
else -> resultValue as? T
|
||||
} ?: defaultValue
|
||||
}
|
||||
|
||||
private fun String.decrypt() = if (encrypt) cryptoUtils.decrypt(this) else this
|
||||
|
||||
inner class TouchinEditor : SharedPreferences.Editor {
|
||||
|
||||
override fun clear() = currentPreferences.edit().clear()
|
||||
|
||||
override fun putLong(key: String?, value: Long) = put(key, value)
|
||||
|
||||
override fun putInt(key: String?, value: Int) = put(key, value)
|
||||
|
||||
override fun remove(key: String?) = currentPreferences.edit().remove(key)
|
||||
|
||||
override fun putBoolean(key: String?, value: Boolean) = put(key, value)
|
||||
|
||||
override fun putStringSet(key: String?, value: MutableSet<String>?): SharedPreferences.Editor {
|
||||
return if (encrypt) {
|
||||
currentPreferences.edit().putStringSet(key, value?.map { it.encrypt() }?.toMutableSet())
|
||||
} else {
|
||||
currentPreferences.edit().putStringSet(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
override fun commit() = currentPreferences.edit().commit()
|
||||
|
||||
override fun putFloat(key: String?, value: Float) = put(key, value)
|
||||
|
||||
override fun apply() = currentPreferences.edit().apply()
|
||||
|
||||
override fun putString(key: String?, value: String?) = put(key, value)
|
||||
|
||||
private fun <T> put(key: String?, value: T): SharedPreferences.Editor {
|
||||
val resultValue = value?.toString()?.chunked(ENCRYPT_BLOCK_SIZE)?.joinToString(separator = "", transform = { it.encrypt() })
|
||||
|
||||
return currentPreferences.edit().putString(key, resultValue)
|
||||
}
|
||||
|
||||
private fun String.encrypt() = if (encrypt) cryptoUtils.encrypt(this) else this
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
package ru.touchin.roboswag
|
||||
|
||||
import android.content.SharedPreferences
|
||||
|
||||
fun CipherSharedPreferences.migrateFromSharedPreferences(from: SharedPreferences, key: String): SharedPreferences {
|
||||
if (!from.contains(key)) return this
|
||||
edit().putString(key, from.getString(key, "") ?: "").apply()
|
||||
from.edit().remove(key).apply()
|
||||
return this
|
||||
}
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
package ru.touchin.roboswag
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.security.KeyPairGeneratorSpec
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import java.math.BigInteger
|
||||
import java.security.KeyPair
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.KeyStore
|
||||
import java.security.PrivateKey
|
||||
import java.util.Calendar
|
||||
import javax.crypto.Cipher
|
||||
import javax.security.auth.x500.X500Principal
|
||||
|
||||
// https://proandroiddev.com/secure-data-in-android-encryption-in-android-part-2-991a89e55a23
|
||||
@Suppress("detekt.TooGenericExceptionCaught")
|
||||
class PrefsCryptoUtils constructor(val context: Context) {
|
||||
|
||||
companion object {
|
||||
|
||||
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
|
||||
private const val KEY_ALGORITHM_RSA = "RSA"
|
||||
private const val TRANSFORMATION_ASYMMETRIC = "RSA/ECB/PKCS1Padding"
|
||||
private const val CIPHER_STRING_SIZE_BYTES = 256
|
||||
private const val BASE_64_PADDING = 2
|
||||
private const val STORAGE_KEY = "STORAGE_KEY"
|
||||
|
||||
//https://stackoverflow.com/questions/13378815/base64-length-calculation
|
||||
private const val DECRYPTED_BYTES_COUNT = 3
|
||||
private const val ENCRYPTED_BYTES_COUNT = 4
|
||||
private const val BASE64_DIVIDER_COUNT = 5
|
||||
const val ENCRYPT_BASE64_STRING_LENGTH =
|
||||
(CIPHER_STRING_SIZE_BYTES + BASE_64_PADDING) * ENCRYPTED_BYTES_COUNT / DECRYPTED_BYTES_COUNT + BASE64_DIVIDER_COUNT
|
||||
const val ENCRYPT_BLOCK_SIZE = 128
|
||||
|
||||
private fun getAndroidKeystore(): KeyStore? = try {
|
||||
KeyStore.getInstance(ANDROID_KEY_STORE).also { it.load(null) }
|
||||
} catch (exception: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun getAndroidKeyStoreAsymmetricKeyPair(): KeyPair? {
|
||||
val privateKey = getAndroidKeystore()?.getKey(STORAGE_KEY, null) as PrivateKey?
|
||||
val publicKey = getAndroidKeystore()?.getCertificate(STORAGE_KEY)?.publicKey
|
||||
return if (privateKey != null && publicKey != null) {
|
||||
KeyPair(publicKey, privateKey)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAndroidKeyStoreAsymmetricKey(context: Context): KeyPair {
|
||||
val generator = KeyPairGenerator.getInstance(KEY_ALGORITHM_RSA, ANDROID_KEY_STORE)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
initGeneratorWithKeyPairGeneratorSpec(generator)
|
||||
} else {
|
||||
initGeneratorWithKeyGenParameterSpec(generator, context)
|
||||
}
|
||||
|
||||
// Generates Key with given spec and saves it to the KeyStore
|
||||
return generator.generateKeyPair()
|
||||
}
|
||||
|
||||
private fun initGeneratorWithKeyGenParameterSpec(generator: KeyPairGenerator, context: Context) {
|
||||
val startDate = Calendar.getInstance()
|
||||
val endDate = Calendar.getInstance()
|
||||
endDate.add(Calendar.YEAR, 20)
|
||||
|
||||
val builder = KeyPairGeneratorSpec.Builder(context)
|
||||
.setAlias(STORAGE_KEY)
|
||||
.setSerialNumber(BigInteger.ONE)
|
||||
.setSubject(X500Principal("CN=$STORAGE_KEY CA Certificate"))
|
||||
.setStartDate(startDate.time)
|
||||
.setEndDate(endDate.time)
|
||||
|
||||
generator.initialize(builder.build())
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.M)
|
||||
private fun initGeneratorWithKeyPairGeneratorSpec(generator: KeyPairGenerator) {
|
||||
val builder = KeyGenParameterSpec.Builder(STORAGE_KEY, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_ECB)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1)
|
||||
generator.initialize(builder.build())
|
||||
}
|
||||
|
||||
private fun createCipher(): Cipher? = try {
|
||||
Cipher.getInstance(TRANSFORMATION_ASYMMETRIC)
|
||||
} catch (exception: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val cipher = createCipher()
|
||||
private val keyPair = getAndroidKeyStoreAsymmetricKeyPair()
|
||||
?: createAndroidKeyStoreAsymmetricKey(context)
|
||||
|
||||
// Those methods should not take and return strings, only char[] and those arrays should be cleared right after usage
|
||||
// See for explanation https://docs.oracle.com/javase/6/docs/technotes/guides/security/crypto/CryptoSpec.html#PBEEx
|
||||
|
||||
@Synchronized
|
||||
fun encrypt(data: String): String {
|
||||
cipher?.init(Cipher.ENCRYPT_MODE, keyPair.public)
|
||||
val bytes = cipher?.doFinal(data.toByteArray())
|
||||
return Base64.encodeToString(bytes, Base64.DEFAULT)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun decrypt(data: String?): String {
|
||||
cipher?.init(Cipher.DECRYPT_MODE, keyPair.private)
|
||||
if (data.isNullOrBlank()) {
|
||||
return String()
|
||||
}
|
||||
val encryptedData = Base64.decode(data, Base64.DEFAULT)
|
||||
val decodedData = cipher?.doFinal(encryptedData)
|
||||
return decodedData?.let { decodedData -> String(decodedData) } ?: ""
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
mvi_arch
|
||||
====
|
||||
|
||||
TODO: rewrite dependencies
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
plugins {
|
||||
id(Plugins.ANDROID_LIB_PLUGIN_WITH_DEFAULT_CONFIG)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
androidX()
|
||||
fragment()
|
||||
lifecycle()
|
||||
|
||||
dagger()
|
||||
|
||||
coroutines()
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<manifest package="ru.touchin.mvi_arch" />
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
package ru.touchin.roboswag.mvi_arch.core
|
||||
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.view.View
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import ru.touchin.extensions.setOnRippleClickListener
|
||||
import ru.touchin.roboswag.mvi_arch.di.ViewModelAssistedFactory
|
||||
import ru.touchin.roboswag.mvi_arch.di.ViewModelFactory
|
||||
import ru.touchin.roboswag.mvi_arch.marker.ViewAction
|
||||
import ru.touchin.roboswag.mvi_arch.marker.ViewState
|
||||
import ru.touchin.roboswag.navigation_base.fragments.BaseFragment
|
||||
import ru.touchin.roboswag.navigation_base.fragments.EmptyState
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Base [Fragment] to use in MVI architecture.
|
||||
*
|
||||
* @param NavArgs Type of arguments class of this screen.
|
||||
* It must implement [NavArgs] interface provided by navigation library that is a part of Google Jetpack.
|
||||
* An instance of this class is generated by [SafeArgs](https://developer.android.com/guide/navigation/navigation-pass-data#Safe-args)
|
||||
* plugin according to related configuration file in navigation resource folder of your project.
|
||||
*
|
||||
* @param State Type of view state class of this screen.
|
||||
* It must implement [ViewState] interface. Usually it's a data class that presents full state of current screen's view.
|
||||
* @see [ViewState] for more information.
|
||||
*
|
||||
* @param Action Type of view actions class of this screen.
|
||||
* It must implement [Action] interface. Usually it's a sealed class that contains classes and objects representing
|
||||
* view actions of this view, e.g. button clicks, text changes, etc.
|
||||
* @see [Action] for more information.
|
||||
*
|
||||
* @param VM Type of view model class of this screen.
|
||||
* It must extends [MviViewModel] class with the same params.
|
||||
* @see [MviViewModel] for more information.
|
||||
*
|
||||
* @author Created by Max Bachinsky and Ivan Vlasov at Touch Instinct.
|
||||
*/
|
||||
abstract class MviFragment<NavArgs, State, Action, VM>(
|
||||
@LayoutRes layout: Int,
|
||||
navArgs: NavArgs = EmptyState as NavArgs
|
||||
) : BaseFragment<FragmentActivity>(layout)
|
||||
where NavArgs : Parcelable,
|
||||
State : ViewState,
|
||||
Action : ViewAction,
|
||||
VM : MviViewModel<NavArgs, Action, State> {
|
||||
|
||||
companion object {
|
||||
const val INIT_ARGS_KEY = "INIT_ARGS"
|
||||
}
|
||||
|
||||
/**
|
||||
* Use [viewModel] extension to get an instance of your view model class.
|
||||
*/
|
||||
protected abstract val viewModel: VM
|
||||
|
||||
/**
|
||||
* Used for smooth view model injection to this class.
|
||||
*/
|
||||
@Inject
|
||||
lateinit var viewModelMap: MutableMap<Class<out ViewModel>, ViewModelAssistedFactory<out ViewModel>>
|
||||
|
||||
init {
|
||||
arguments?.putParcelable(INIT_ARGS_KEY, navArgs) ?: let {
|
||||
arguments = bundleOf(INIT_ARGS_KEY to navArgs)
|
||||
}
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
viewModel.state.observe(viewLifecycleOwner, Observer(this::renderState))
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this method to subscribe on view state changes.
|
||||
*
|
||||
* You should render view state here.
|
||||
*
|
||||
* Must not be called before [onAttach] and after [onDetach].
|
||||
*/
|
||||
protected open fun renderState(viewState: State) {}
|
||||
|
||||
/**
|
||||
* Use this method to dispatch view actions to view model.
|
||||
*/
|
||||
protected fun dispatchAction(actionProvider: () -> Action) {
|
||||
viewModel.dispatchAction(actionProvider.invoke())
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this method to dispatch view actions to view model.
|
||||
*/
|
||||
protected fun dispatchAction(action: Action) {
|
||||
viewModel.dispatchAction(action)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily provides view model of this screen with transmitted arguments if exist.
|
||||
*
|
||||
* Value of this lazily providing must not be accessed before [onAttach] and after [onDetach].
|
||||
*/
|
||||
@MainThread
|
||||
protected inline fun <reified ViewModel : VM> viewModel(): Lazy<ViewModel> =
|
||||
lazy {
|
||||
val fragmentArguments = arguments ?: bundleOf()
|
||||
|
||||
ViewModelProvider(
|
||||
viewModelStore,
|
||||
ViewModelFactory(viewModelMap, this, fragmentArguments)
|
||||
).get(ViewModel::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple extension for dispatching view events to view model with on click.
|
||||
*/
|
||||
protected fun View.dispatchActionOnClick(actionProvider: () -> Action) {
|
||||
setOnClickListener { dispatchAction(actionProvider) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple extension for dispatching view events to view model with on click.
|
||||
*/
|
||||
protected fun View.dispatchActionOnClick(action: Action) {
|
||||
setOnClickListener { dispatchAction(action) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple extension for dispatching view events to view model with on ripple click.
|
||||
*/
|
||||
protected fun View.dispatchActionOnRippleClick(actionProvider: () -> Action) {
|
||||
setOnRippleClickListener { dispatchAction(actionProvider) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple extension for dispatching view events to view model with on ripple click.
|
||||
*/
|
||||
protected fun View.dispatchActionOnRippleClick(action: Action) {
|
||||
setOnRippleClickListener { dispatchAction(action) }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
package ru.touchin.roboswag.mvi_arch.core
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import ru.touchin.roboswag.mvi_arch.marker.SideEffect
|
||||
import ru.touchin.roboswag.mvi_arch.marker.StateChange
|
||||
import ru.touchin.roboswag.mvi_arch.marker.ViewAction
|
||||
import ru.touchin.roboswag.mvi_arch.marker.ViewState
|
||||
|
||||
/**
|
||||
* Base [ViewModel] to use in MVI architecture.
|
||||
*
|
||||
* @param NavArgs Type of arguments class of this screen.
|
||||
* It must implement [NavArgs] interface provided by navigation library that is a part of Google Jetpack.
|
||||
* An instance of this class is generated by [SafeArgs](https://developer.android.com/guide/navigation/navigation-pass-data#Safe-args)
|
||||
* plugin according to related configuration file in navigation resource folder of your project.
|
||||
*
|
||||
* @param State Type of view state class of this screen.
|
||||
* It must implement [ViewState] interface. Usually it's a data class that presents full state of current screen's view.
|
||||
* @see [ViewState] for more information.
|
||||
*
|
||||
* @param Action Type of view actions class of this screen.
|
||||
* It must implement [Action] interface. Usually it's a sealed class that contains classes and objects representing
|
||||
* view actions of this view, e.g. button clicks, text changes, etc.
|
||||
* @see [Action] for more information.
|
||||
*
|
||||
* @author Created by Max Bachinsky and Ivan Vlasov at Touch Instinct.
|
||||
*/
|
||||
|
||||
abstract class MviStoreViewModel<NavArgs : Parcelable, Action : ViewAction, State : ViewState>(
|
||||
initialState: State,
|
||||
handle: SavedStateHandle
|
||||
) : MviViewModel<NavArgs, Action, State>(initialState, handle) {
|
||||
|
||||
private lateinit var store: ChildStore<*, *, *>
|
||||
|
||||
protected fun <IChange : StateChange, IEffect : SideEffect, IState : ViewState> connectStore(
|
||||
store: Store<IChange, IEffect, IState>,
|
||||
mapAction: (Action) -> IChange?,
|
||||
mapState: (IState) -> State
|
||||
) {
|
||||
this.store = ChildStore(store, mapAction)
|
||||
|
||||
store
|
||||
.observeState()
|
||||
.map { mapState(it) }
|
||||
.onEach { this._state.postValue(it) }
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
override fun dispatchAction(action: Action) {
|
||||
store.dispatchAction(action)
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
store.onCleared()
|
||||
}
|
||||
|
||||
private inner class ChildStore<IChange : StateChange, IEffect : SideEffect, IState : ViewState>(
|
||||
val store: Store<IChange, IEffect, IState>,
|
||||
val changeMapper: (Action) -> IChange?
|
||||
) {
|
||||
fun onCleared() {
|
||||
store.onCleared()
|
||||
}
|
||||
|
||||
fun dispatchAction(action: Action) {
|
||||
changeMapper(action)?.let(store::changeState)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package ru.touchin.roboswag.mvi_arch.core
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.Transformations
|
||||
import androidx.lifecycle.ViewModel
|
||||
import ru.touchin.roboswag.mvi_arch.marker.ViewAction
|
||||
import ru.touchin.roboswag.mvi_arch.marker.ViewState
|
||||
|
||||
/**
|
||||
* Base [ViewModel] to use in MVI architecture.
|
||||
*
|
||||
* @param NavArgs Type of arguments class of this screen.
|
||||
* It must implement [NavArgs] interface provided by navigation library that is a part of Google Jetpack.
|
||||
* An instance of this class is generated by [SafeArgs](https://developer.android.com/guide/navigation/navigation-pass-data#Safe-args)
|
||||
* plugin according to related configuration file in navigation resource folder of your project.
|
||||
*
|
||||
* @param State Type of view state class of this screen.
|
||||
* It must implement [ViewState] interface. Usually it's a data class that presents full state of current screen's view.
|
||||
* @see [ViewState] for more information.
|
||||
*
|
||||
* @param Action Type of view actions class of this screen.
|
||||
* It must implement [Action] interface. Usually it's a sealed class that contains classes and objects representing
|
||||
* view actions of this view, e.g. button clicks, text changes, etc.
|
||||
* @see [Action] for more information.
|
||||
*
|
||||
* @author Created by Max Bachinsky and Ivan Vlasov at Touch Instinct.
|
||||
*/
|
||||
|
||||
abstract class MviViewModel<NavArgs : Parcelable, Action : ViewAction, State : ViewState>(
|
||||
private val initialState: State,
|
||||
protected val handle: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
|
||||
protected val navArgs: NavArgs = handle.get(MviFragment.INIT_ARGS_KEY) ?: throw IllegalStateException("Nav args mustn't be null")
|
||||
|
||||
protected val _state = MutableLiveData(initialState)
|
||||
internal val state = Transformations.distinctUntilChanged(_state)
|
||||
|
||||
protected val currentState: State
|
||||
get() = state.value ?: initialState
|
||||
|
||||
abstract fun dispatchAction(action: Action)
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
package ru.touchin.roboswag.mvi_arch.core
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.consumeAsFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.touchin.roboswag.mvi_arch.marker.SideEffect
|
||||
import ru.touchin.roboswag.mvi_arch.marker.StateChange
|
||||
import ru.touchin.roboswag.mvi_arch.marker.ViewState
|
||||
|
||||
abstract class Store<Change : StateChange, Effect : SideEffect, State : ViewState>(
|
||||
initialState: State
|
||||
) {
|
||||
|
||||
protected val currentState: State
|
||||
get() = state.value
|
||||
|
||||
private val storeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
private val effects = Channel<Effect?>(Channel.UNLIMITED)
|
||||
private val state = MutableStateFlow(initialState)
|
||||
|
||||
private val childStores: MutableList<ChildStore<*, *, *>> = mutableListOf()
|
||||
|
||||
init {
|
||||
storeScope.launch {
|
||||
effects
|
||||
.consumeAsFlow()
|
||||
.filterNotNull()
|
||||
.handleSideEffect()
|
||||
.collect { newChange -> changeState(newChange) }
|
||||
}
|
||||
}
|
||||
|
||||
fun changeState(change: Change) {
|
||||
val (newState, newEffect) = reduce(currentState, change)
|
||||
|
||||
if (currentState != newState) {
|
||||
state.value = newState
|
||||
}
|
||||
|
||||
childStores.forEach { childStore ->
|
||||
childStore.change(change)
|
||||
}
|
||||
|
||||
newEffect?.let {
|
||||
effects.offer(it)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun observeState(): Flow<State> = state
|
||||
|
||||
fun onCleared() {
|
||||
storeScope.coroutineContext.cancel()
|
||||
childStores.forEach(Store<Change, Effect, State>.ChildStore<*, *, *>::onCleared)
|
||||
}
|
||||
|
||||
fun State.only(): Pair<State, Effect?> = this to null
|
||||
|
||||
fun Effect.only(): Pair<State, Effect> = currentState to this
|
||||
|
||||
fun same(): Pair<State, Effect?> = currentState.only()
|
||||
|
||||
protected fun <ChildChange : StateChange, ChildEffect : SideEffect, ChildState : ViewState> addChildStore(
|
||||
store: Store<ChildChange, ChildEffect, ChildState>,
|
||||
changeMapper: (Change) -> ChildChange?,
|
||||
stateMapper: (ChildState) -> State
|
||||
) {
|
||||
childStores.add(ChildStore(store, changeMapper))
|
||||
|
||||
store
|
||||
.observeState()
|
||||
.onEach { state.value = stateMapper(it) }
|
||||
.launchIn(storeScope)
|
||||
|
||||
}
|
||||
|
||||
protected open fun Flow<Effect>.handleSideEffect(): Flow<Change> = emptyFlow()
|
||||
|
||||
protected abstract fun reduce(currentState: State, change: Change): Pair<State, Effect?>
|
||||
|
||||
private inner class ChildStore<ChildChange : StateChange, ChildEffect : SideEffect, ChildState : ViewState>(
|
||||
val store: Store<ChildChange, ChildEffect, ChildState>,
|
||||
val changeMapper: (Change) -> ChildChange?
|
||||
) {
|
||||
fun onCleared() {
|
||||
store.onCleared()
|
||||
}
|
||||
|
||||
fun change(change: Change) {
|
||||
changeMapper(change)?.let(store::changeState)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package ru.touchin.roboswag.mvi_arch.di
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
interface ViewModelAssistedFactory<VM : ViewModel> {
|
||||
fun create(handle: SavedStateHandle): VM
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package ru.touchin.roboswag.mvi_arch.di
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.lifecycle.AbstractSavedStateViewModelFactory
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.savedstate.SavedStateRegistryOwner
|
||||
|
||||
class ViewModelFactory(
|
||||
private val viewModelMap: MutableMap<Class<out ViewModel>, ViewModelAssistedFactory<out ViewModel>>,
|
||||
owner: SavedStateRegistryOwner,
|
||||
arguments: Bundle
|
||||
) : AbstractSavedStateViewModelFactory(owner, arguments) {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel?> create(key: String, modelClass: Class<T>, handle: SavedStateHandle): T {
|
||||
return viewModelMap[modelClass]?.create(handle) as? T ?: throw IllegalStateException("Unknown ViewModel class")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package ru.touchin.roboswag.mvi_arch.di
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dagger.MapKey
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER)
|
||||
@MapKey
|
||||
annotation class ViewModelKey(val value: KClass<out ViewModel>)
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package ru.touchin.roboswag.mvi_arch.marker
|
||||
|
||||
interface SideEffect
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
package ru.touchin.roboswag.mvi_arch.marker
|
||||
|
||||
interface StateChange {
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package ru.touchin.roboswag.mvi_arch.marker
|
||||
|
||||
/**
|
||||
* This interface should be implemented to create your own view actions and use it with [MviFragment] and [MviViewModel].
|
||||
*
|
||||
* Usually it's sealed class with nested classes and objects representing view actions.
|
||||
*
|
||||
* Quite common cases:
|
||||
* 1. View contains simple button:
|
||||
* object OnButtonClicked : YourViewAction()
|
||||
*
|
||||
* 2. View contains button with parameter:
|
||||
* data class OnButtonWithParamClicked(val param: Param): YourViewAction()
|
||||
*
|
||||
* 3. View contains text input field:
|
||||
* data class OnInputChanged(val input: String): YourViewAction()
|
||||
*
|
||||
* Exemplars of this classes used to generate new [ViewState] in [MviViewModel].
|
||||
*
|
||||
* @author Created by Max Bachinsky and Ivan Vlasov at Touch Instinct.
|
||||
*/
|
||||
interface ViewAction
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package ru.touchin.roboswag.mvi_arch.marker
|
||||
|
||||
/**
|
||||
* This interface should be implemented to create your own view state and use it with [MviFragment] and [MviViewModel].
|
||||
*
|
||||
* Usually it's a data class that presents full state of view.
|
||||
*
|
||||
* You should not use mutable values here. All values should be immutable.
|
||||
*
|
||||
* @author Created by Max Bachinsky and Ivan Vlasov at Touch Instinct.
|
||||
*/
|
||||
interface ViewState
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<string name="app_name">mvi-arch</string>
|
||||
</resources>
|
||||
|
|
@ -2,6 +2,10 @@ apply from: "../android-configs/lib-config.gradle"
|
|||
|
||||
apply plugin: 'kotlin-kapt'
|
||||
|
||||
android {
|
||||
buildFeatures.viewBinding = true
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(":utils")
|
||||
implementation project(":logging")
|
||||
|
|
@ -17,6 +21,9 @@ dependencies {
|
|||
implementation "androidx.fragment:fragment"
|
||||
implementation "androidx.fragment:fragment-ktx"
|
||||
|
||||
implementation "androidx.lifecycle:lifecycle-common-java8"
|
||||
implementation "androidx.lifecycle:lifecycle-livedata-ktx"
|
||||
|
||||
implementation("com.crashlytics.sdk.android:crashlytics") {
|
||||
transitive = true
|
||||
}
|
||||
|
|
@ -57,5 +64,17 @@ dependencies {
|
|||
require '2.10.0'
|
||||
}
|
||||
}
|
||||
|
||||
implementation("androidx.lifecycle:lifecycle-common-java8") {
|
||||
version {
|
||||
require '2.2.0'
|
||||
}
|
||||
}
|
||||
|
||||
implementation("androidx.lifecycle:lifecycle-livedata-ktx") {
|
||||
version {
|
||||
require '2.2.0'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
package ru.touchin.roboswag.navigation_base.fragments
|
||||
|
||||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.observe
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import kotlin.properties.ReadOnlyProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
class FragmentViewBindingDelegate<T : ViewBinding>(
|
||||
val fragment: Fragment,
|
||||
val viewBindingFactory: (View) -> T
|
||||
) : ReadOnlyProperty<Fragment, T> {
|
||||
private var binding: T? = null
|
||||
|
||||
init {
|
||||
fragment.lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
override fun onCreate(owner: LifecycleOwner) {
|
||||
fragment.viewLifecycleOwnerLiveData.observe(fragment) { viewLifecycleOwner ->
|
||||
viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
binding = null
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun getValue(thisRef: Fragment, property: KProperty<*>): T {
|
||||
val binding = binding
|
||||
if (binding != null) {
|
||||
return binding
|
||||
}
|
||||
|
||||
val lifecycle = fragment.viewLifecycleOwner.lifecycle
|
||||
if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) {
|
||||
throw IllegalStateException("Should not attempt to get bindings when Fragment views are destroyed.")
|
||||
}
|
||||
|
||||
return viewBindingFactory(thisRef.requireView()).also { this.binding = it }
|
||||
}
|
||||
}
|
||||
|
||||
fun <T : ViewBinding> Fragment.viewBinding(viewBindingFactory: (View) -> T) =
|
||||
FragmentViewBindingDelegate(this, viewBindingFactory)
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
navigation-cicerone
|
||||
====
|
||||
|
||||
TODO: rewrite dependencies
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
plugins {
|
||||
id(Plugins.ANDROID_LIB_PLUGIN_WITH_DEFAULT_CONFIG)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementationModule(Module.Core.DI)
|
||||
implementation(Library.CICERONE)
|
||||
fragment()
|
||||
dagger(withAssistedInject = false)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<manifest package="ru.touchin.mvi_arch.core_nav" />
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package ru.touchin.roboswag.navigation_cicerone
|
||||
|
||||
import androidx.annotation.IdRes
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleObserver
|
||||
import androidx.lifecycle.OnLifecycleEvent
|
||||
import ru.terrakok.cicerone.NavigatorHolder
|
||||
import ru.terrakok.cicerone.android.support.SupportAppNavigator
|
||||
|
||||
class CiceroneTuner(
|
||||
private val activity: FragmentActivity,
|
||||
private val navigatorHolder: NavigatorHolder,
|
||||
@IdRes private val fragmentContainerId: Int,
|
||||
private val fragmentManager: FragmentManager? = null
|
||||
) : LifecycleObserver {
|
||||
|
||||
val navigator by lazy(this::createNavigator)
|
||||
|
||||
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
|
||||
fun addNavigator() {
|
||||
navigatorHolder.setNavigator(navigator)
|
||||
}
|
||||
|
||||
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
|
||||
fun removeNavigator() {
|
||||
navigatorHolder.removeNavigator()
|
||||
}
|
||||
|
||||
private fun createNavigator() = if (fragmentManager != null) {
|
||||
SupportAppNavigator(
|
||||
activity,
|
||||
fragmentManager,
|
||||
fragmentContainerId
|
||||
)
|
||||
} else {
|
||||
SupportAppNavigator(
|
||||
activity,
|
||||
fragmentContainerId
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package ru.touchin.roboswag.navigation_cicerone.flow
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import ru.terrakok.cicerone.NavigatorHolder
|
||||
import ru.terrakok.cicerone.Router
|
||||
import ru.terrakok.cicerone.android.support.SupportAppScreen
|
||||
import ru.touchin.mvi_arch.core_nav.R
|
||||
import ru.touchin.roboswag.navigation_cicerone.CiceroneTuner
|
||||
import javax.inject.Inject
|
||||
|
||||
abstract class FlowFragment : Fragment(R.layout.fragment_flow) {
|
||||
|
||||
@Inject
|
||||
@FlowNavigation
|
||||
lateinit var navigatorHolder: NavigatorHolder
|
||||
|
||||
@Inject
|
||||
@FlowNavigation
|
||||
lateinit var router: Router
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
injectComponent()
|
||||
if (childFragmentManager.fragments.isEmpty()) {
|
||||
router.newRootScreen(getLaunchScreen())
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun injectComponent()
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
viewLifecycleOwner.lifecycle.addObserver(
|
||||
CiceroneTuner(
|
||||
activity = requireActivity(),
|
||||
navigatorHolder = navigatorHolder,
|
||||
fragmentContainerId = R.id.flow_parent,
|
||||
fragmentManager = childFragmentManager
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
abstract fun getLaunchScreen(): SupportAppScreen
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package ru.touchin.roboswag.navigation_cicerone.flow
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
@Qualifier
|
||||
annotation class FlowNavigation
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package ru.touchin.roboswag.navigation_cicerone.flow
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import ru.terrakok.cicerone.Cicerone
|
||||
import ru.terrakok.cicerone.NavigatorHolder
|
||||
import ru.terrakok.cicerone.Router
|
||||
import ru.touchin.mvi_arch.di.FeatureScope
|
||||
|
||||
@Module
|
||||
class FlowNavigationModule {
|
||||
|
||||
@Provides
|
||||
@FlowNavigation
|
||||
@FeatureScope
|
||||
fun provideCicerone(): Cicerone<Router> = Cicerone.create()
|
||||
|
||||
@Provides
|
||||
@FlowNavigation
|
||||
fun provideNavigatorHolder(@FlowNavigation cicerone: Cicerone<Router>): NavigatorHolder = cicerone.navigatorHolder
|
||||
|
||||
@Provides
|
||||
@FlowNavigation
|
||||
fun provideRouter(@FlowNavigation cicerone: Cicerone<Router>): Router = cicerone.router
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/flow_parent"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<string name="app_name">mvi-arch</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
pagination
|
||||
====
|
||||
|
||||
TODO: rewrite dependencies
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
plugins {
|
||||
id(Plugins.ANDROID_LIB_PLUGIN_WITH_DEFAULT_CONFIG)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
mvi()
|
||||
materialDesign()
|
||||
recyclerView()
|
||||
implementationModule(Module.RoboSwag.KOTLIN_EXTENSIONS)
|
||||
implementationModule(Module.RoboSwag.VIEWS)
|
||||
implementationModule(Module.RoboSwag.UTILS)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<manifest package="ru.touchin.mvi_arch.core_pagination" />
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package ru.touchin.roboswag.pagination
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import ru.touchin.adapters.AdapterDelegate
|
||||
import ru.touchin.adapters.DelegationListAdapter
|
||||
import ru.touchin.mvi_test.core_ui.pagination.ProgressItem
|
||||
|
||||
class PaginationAdapter(
|
||||
private val nextPageCallback: () -> Unit,
|
||||
private val itemIdDiff: (old: Any, new: Any) -> Boolean,
|
||||
vararg delegate: AdapterDelegate<out RecyclerView.ViewHolder>
|
||||
) : DelegationListAdapter<Any>(
|
||||
object : DiffUtil.ItemCallback<Any>() {
|
||||
override fun areItemsTheSame(oldItem: Any, newItem: Any): Boolean = itemIdDiff(oldItem, newItem)
|
||||
|
||||
@SuppressLint("DiffUtilEquals")
|
||||
override fun areContentsTheSame(oldItem: Any, newItem: Any): Boolean = oldItem == newItem
|
||||
}
|
||||
) {
|
||||
|
||||
internal var fullData = false
|
||||
|
||||
init {
|
||||
addDelegate(ProgressAdapterDelegate())
|
||||
delegate.forEach(this::addDelegate)
|
||||
}
|
||||
|
||||
fun update(data: List<Any>, isPageProgress: Boolean) {
|
||||
submitList(data + listOfNotNull(ProgressItem.takeIf { isPageProgress }))
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: List<Any>) {
|
||||
super.onBindViewHolder(holder, position, payloads)
|
||||
if (!fullData && position >= itemCount - 10) nextPageCallback.invoke()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package ru.touchin.roboswag.pagination
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.FrameLayout
|
||||
import androidx.recyclerview.widget.StaggeredGridLayoutManager
|
||||
import ru.touchin.extensions.setOnRippleClickListener
|
||||
import ru.touchin.mvi_arch.core_pagination.databinding.ViewPaginationBinding
|
||||
import ru.touchin.mvi_test.core_ui.pagination.Paginator
|
||||
|
||||
// TODO: add an errorview with empty state and error text
|
||||
class PaginationView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : FrameLayout(context, attrs) {
|
||||
|
||||
private lateinit var refreshCallback: (() -> Unit)
|
||||
private lateinit var adapter: PaginationAdapter
|
||||
|
||||
private val binding = ViewPaginationBinding.inflate(LayoutInflater.from(context), this, true)
|
||||
|
||||
init {
|
||||
with(binding) {
|
||||
swipeToRefresh.setOnRefreshListener { refreshCallback() }
|
||||
elementsRecycler.layoutManager = StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL)
|
||||
emptyText.setOnRippleClickListener { refreshCallback() }
|
||||
}
|
||||
}
|
||||
|
||||
fun init(refreshCallback: () -> Unit, adapter: PaginationAdapter) {
|
||||
this.refreshCallback = refreshCallback
|
||||
this.adapter = adapter
|
||||
binding.elementsRecycler.adapter = adapter
|
||||
}
|
||||
|
||||
fun render(state: Paginator.State) {
|
||||
with(binding) {
|
||||
swipeToRefresh.isRefreshing = state is Paginator.State.Refresh<*>
|
||||
swipeToRefresh.isEnabled = state !is Paginator.State.EmptyProgress
|
||||
switcher.showChild(
|
||||
when (state) {
|
||||
Paginator.State.Empty, is Paginator.State.EmptyError -> emptyText.id
|
||||
Paginator.State.EmptyProgress -> progressBar.id
|
||||
else -> elementsRecycler.id
|
||||
}
|
||||
)
|
||||
adapter.fullData = state === Paginator.State.Empty || state is Paginator.State.FullData<*>
|
||||
|
||||
when (state) {
|
||||
is Paginator.State.Empty -> {
|
||||
adapter.update(emptyList(), false)
|
||||
}
|
||||
is Paginator.State.EmptyProgress -> {
|
||||
adapter.update(emptyList(), false)
|
||||
}
|
||||
is Paginator.State.EmptyError -> {
|
||||
adapter.update(emptyList(), false)
|
||||
}
|
||||
is Paginator.State.Data<*> -> {
|
||||
adapter.update(state.data as List<Any>, false)
|
||||
}
|
||||
is Paginator.State.Refresh<*> -> {
|
||||
adapter.update(state.data as List<Any>, false)
|
||||
}
|
||||
is Paginator.State.NewPageProgress<*> -> {
|
||||
adapter.update(state.data as List<Any>, true)
|
||||
}
|
||||
is Paginator.State.FullData<*> -> {
|
||||
adapter.update(state.data as List<Any>, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
package ru.touchin.mvi_test.core_ui.pagination
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import ru.touchin.roboswag.mvi_arch.core.Store
|
||||
import ru.touchin.roboswag.mvi_arch.marker.SideEffect
|
||||
import ru.touchin.roboswag.mvi_arch.marker.StateChange
|
||||
import ru.touchin.roboswag.mvi_arch.marker.ViewState
|
||||
|
||||
class Paginator<Item>(
|
||||
private val showError: (Error) -> Unit,
|
||||
private val loadPage: suspend (Int) -> List<Item>
|
||||
) : Store<Paginator.Change, Paginator.Effect, Paginator.State>(State.Empty) {
|
||||
|
||||
sealed class Change : StateChange {
|
||||
object Refresh : Change()
|
||||
object Restart : Change()
|
||||
object LoadMore : Change()
|
||||
object Reset : Change()
|
||||
data class NewPageLoaded<T>(val pageNumber: Int, val items: List<T>) : Change()
|
||||
data class PageLoadError(val error: Throwable) : Change()
|
||||
}
|
||||
|
||||
sealed class Effect : SideEffect {
|
||||
data class LoadPage(val page: Int = 0) : Effect()
|
||||
}
|
||||
|
||||
sealed class State : ViewState {
|
||||
object Empty : State()
|
||||
object EmptyProgress : State()
|
||||
data class EmptyError(val error: Throwable) : State()
|
||||
data class Data<T>(val pageCount: Int = 0, val data: List<T>) : State()
|
||||
data class Refresh<T>(val pageCount: Int, val data: List<T>) : State()
|
||||
data class NewPageProgress<T>(val pageCount: Int, val data: List<T>) : State()
|
||||
data class FullData<T>(val pageCount: Int, val data: List<T>) : State()
|
||||
}
|
||||
|
||||
sealed class Error {
|
||||
object NewPageFailed : Error()
|
||||
object RefreshFailed : Error()
|
||||
}
|
||||
|
||||
override fun reduce(currentState: State, change: Change): Pair<State, Effect?> = when (change) {
|
||||
Change.Refresh -> {
|
||||
when (currentState) {
|
||||
State.Empty -> State.EmptyProgress
|
||||
is State.EmptyError -> State.EmptyProgress
|
||||
is State.Data<*> -> State.Refresh(currentState.pageCount, currentState.data)
|
||||
is State.NewPageProgress<*> -> State.Refresh(currentState.pageCount, currentState.data)
|
||||
is State.FullData<*> -> State.Refresh(currentState.pageCount, currentState.data)
|
||||
else -> currentState
|
||||
} to Effect.LoadPage()
|
||||
}
|
||||
Change.Restart -> {
|
||||
State.EmptyProgress to Effect.LoadPage()
|
||||
}
|
||||
Change.LoadMore -> {
|
||||
when (currentState) {
|
||||
is State.Data<*> -> {
|
||||
State.NewPageProgress(currentState.pageCount, currentState.data) to Effect.LoadPage(currentState.pageCount + 1)
|
||||
}
|
||||
else -> currentState.only()
|
||||
}
|
||||
}
|
||||
Change.Reset -> {
|
||||
State.Empty.only()
|
||||
}
|
||||
is Change.NewPageLoaded<*> -> {
|
||||
val items = change.items
|
||||
when (currentState) {
|
||||
is State.EmptyProgress -> {
|
||||
if (items.isEmpty()) {
|
||||
State.Empty
|
||||
} else {
|
||||
State.Data(0, items)
|
||||
}
|
||||
}
|
||||
is State.Refresh<*> -> {
|
||||
if (items.isEmpty()) {
|
||||
State.Empty
|
||||
} else {
|
||||
State.Data(0, items)
|
||||
}
|
||||
}
|
||||
is State.NewPageProgress<*> -> {
|
||||
if (items.isEmpty()) {
|
||||
State.FullData(currentState.pageCount, currentState.data)
|
||||
} else {
|
||||
State.Data(currentState.pageCount + 1, currentState.data + items)
|
||||
}
|
||||
}
|
||||
else -> currentState
|
||||
}.only()
|
||||
}
|
||||
is Change.PageLoadError -> {
|
||||
when (currentState) {
|
||||
is State.EmptyProgress -> State.EmptyError(change.error)
|
||||
is State.Refresh<*> -> {
|
||||
showError(Error.RefreshFailed)
|
||||
State.Data(currentState.pageCount, currentState.data)
|
||||
}
|
||||
is State.NewPageProgress<*> -> {
|
||||
showError(Error.NewPageFailed)
|
||||
State.Data(currentState.pageCount, currentState.data)
|
||||
}
|
||||
else -> currentState
|
||||
}.only()
|
||||
}
|
||||
}
|
||||
|
||||
override fun Flow<Effect>.handleSideEffect(): Flow<Change> = flatMapLatest { effect ->
|
||||
flow {
|
||||
when (effect) {
|
||||
is Effect.LoadPage -> {
|
||||
try {
|
||||
val items = loadPage(effect.page)
|
||||
emit(Change.NewPageLoaded(effect.page, items))
|
||||
} catch (e: Exception) {
|
||||
emit(Change.PageLoadError(e))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package ru.touchin.roboswag.pagination
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import ru.touchin.adapters.ItemAdapterDelegate
|
||||
import ru.touchin.mvi_arch.core_pagination.R
|
||||
import ru.touchin.mvi_test.core_ui.pagination.ProgressItem
|
||||
import ru.touchin.roboswag.components.utils.UiUtils
|
||||
|
||||
class ProgressAdapterDelegate : ItemAdapterDelegate<RecyclerView.ViewHolder, ProgressItem>() {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup): RecyclerView.ViewHolder =
|
||||
object : RecyclerView.ViewHolder(UiUtils.inflate(R.layout.item_progress, parent)) {}
|
||||
|
||||
override fun onBindViewHolder(
|
||||
holder: RecyclerView.ViewHolder,
|
||||
item: ProgressItem,
|
||||
adapterPosition: Int,
|
||||
collectionPosition: Int,
|
||||
payloads: MutableList<Any>
|
||||
) = Unit
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package ru.touchin.mvi_test.core_ui.pagination
|
||||
|
||||
object ProgressItem
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp">
|
||||
|
||||
<ProgressBar
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center" />
|
||||
|
||||
</FrameLayout>
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/swipe_to_refresh"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ru.touchin.widget.Switcher
|
||||
android:id="@+id/switcher"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/empty_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:text="Не загрузилося" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress_bar"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/elements_recycler"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false" />
|
||||
|
||||
</ru.touchin.widget.Switcher>
|
||||
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="paginator_new_page_error_message">New page load error. Try again.</string>
|
||||
<string name="paginator_refresh_error_message">Refresh screen error. Try again.</string>
|
||||
</resources>
|
||||
Loading…
Reference in New Issue