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
|
* 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 разработки, позволяет разбивать код на мелкие и независимые части, что ускоряет разработку и последующую поддержку приложения.
|
За основу архитектуры взят подход от 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'
|
apply plugin: 'kotlin-kapt'
|
||||||
|
|
||||||
|
android {
|
||||||
|
buildFeatures.viewBinding = true
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation project(":utils")
|
implementation project(":utils")
|
||||||
implementation project(":logging")
|
implementation project(":logging")
|
||||||
|
|
@ -17,6 +21,9 @@ dependencies {
|
||||||
implementation "androidx.fragment:fragment"
|
implementation "androidx.fragment:fragment"
|
||||||
implementation "androidx.fragment:fragment-ktx"
|
implementation "androidx.fragment:fragment-ktx"
|
||||||
|
|
||||||
|
implementation "androidx.lifecycle:lifecycle-common-java8"
|
||||||
|
implementation "androidx.lifecycle:lifecycle-livedata-ktx"
|
||||||
|
|
||||||
implementation("com.crashlytics.sdk.android:crashlytics") {
|
implementation("com.crashlytics.sdk.android:crashlytics") {
|
||||||
transitive = true
|
transitive = true
|
||||||
}
|
}
|
||||||
|
|
@ -57,5 +64,17 @@ dependencies {
|
||||||
require '2.10.0'
|
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