diff --git a/README.md b/README.md
index b9fbe85..f48dec0 100644
--- a/README.md
+++ b/README.md
@@ -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 разработки, позволяет разбивать код на мелкие и независимые части, что ускоряет разработку и последующую поддержку приложения.
diff --git a/encrypted-shared-prefs/README.md b/encrypted-shared-prefs/README.md
deleted file mode 100644
index 481ca7d..0000000
--- a/encrypted-shared-prefs/README.md
+++ /dev/null
@@ -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` должен быть в соответствующем положении
diff --git a/encrypted-shared-prefs/build.gradle b/encrypted-shared-prefs/build.gradle
deleted file mode 100644
index 085c337..0000000
--- a/encrypted-shared-prefs/build.gradle
+++ /dev/null
@@ -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"
-
-}
diff --git a/encrypted-shared-prefs/src/main/AndroidManifest.xml b/encrypted-shared-prefs/src/main/AndroidManifest.xml
deleted file mode 100644
index a068e5f..0000000
--- a/encrypted-shared-prefs/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/encrypted-shared-prefs/src/main/java/ru/touchin/roboswag/CipherSharedPreferences.kt b/encrypted-shared-prefs/src/main/java/ru/touchin/roboswag/CipherSharedPreferences.kt
deleted file mode 100644
index 65eb56d..0000000
--- a/encrypted-shared-prefs/src/main/java/ru/touchin/roboswag/CipherSharedPreferences.kt
+++ /dev/null
@@ -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 {
- 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?): MutableSet? {
- 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 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?): 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 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
-
- }
-
-}
diff --git a/encrypted-shared-prefs/src/main/java/ru/touchin/roboswag/Extensions.kt b/encrypted-shared-prefs/src/main/java/ru/touchin/roboswag/Extensions.kt
deleted file mode 100644
index bcab65b..0000000
--- a/encrypted-shared-prefs/src/main/java/ru/touchin/roboswag/Extensions.kt
+++ /dev/null
@@ -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
-}
diff --git a/encrypted-shared-prefs/src/main/java/ru/touchin/roboswag/PrefsCryptoUtils.kt b/encrypted-shared-prefs/src/main/java/ru/touchin/roboswag/PrefsCryptoUtils.kt
deleted file mode 100644
index 3a3c888..0000000
--- a/encrypted-shared-prefs/src/main/java/ru/touchin/roboswag/PrefsCryptoUtils.kt
+++ /dev/null
@@ -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) } ?: ""
- }
-
-}
diff --git a/encrypted-shared-prefs/.gitignore b/mvi-arch/.gitignore
similarity index 100%
rename from encrypted-shared-prefs/.gitignore
rename to mvi-arch/.gitignore
diff --git a/mvi-arch/README.md b/mvi-arch/README.md
new file mode 100644
index 0000000..6cd5bb4
--- /dev/null
+++ b/mvi-arch/README.md
@@ -0,0 +1,4 @@
+mvi_arch
+====
+
+TODO: rewrite dependencies
diff --git a/mvi-arch/build.gradle.kts b/mvi-arch/build.gradle.kts
new file mode 100644
index 0000000..99febc6
--- /dev/null
+++ b/mvi-arch/build.gradle.kts
@@ -0,0 +1,13 @@
+plugins {
+ id(Plugins.ANDROID_LIB_PLUGIN_WITH_DEFAULT_CONFIG)
+}
+
+dependencies {
+ androidX()
+ fragment()
+ lifecycle()
+
+ dagger()
+
+ coroutines()
+}
diff --git a/mvi-arch/src/main/AndroidManifest.xml b/mvi-arch/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..25831d9
--- /dev/null
+++ b/mvi-arch/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/core/MviFragment.kt b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/core/MviFragment.kt
new file mode 100644
index 0000000..2cf6857
--- /dev/null
+++ b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/core/MviFragment.kt
@@ -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(
+ @LayoutRes layout: Int,
+ navArgs: NavArgs = EmptyState as NavArgs
+) : BaseFragment(layout)
+ where NavArgs : Parcelable,
+ State : ViewState,
+ Action : ViewAction,
+ VM : MviViewModel {
+
+ 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, ViewModelAssistedFactory>
+
+ 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 viewModel(): Lazy =
+ 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) }
+ }
+
+}
diff --git a/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/core/MviStoreViewModel.kt b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/core/MviStoreViewModel.kt
new file mode 100644
index 0000000..811a8cd
--- /dev/null
+++ b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/core/MviStoreViewModel.kt
@@ -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(
+ initialState: State,
+ handle: SavedStateHandle
+) : MviViewModel(initialState, handle) {
+
+ private lateinit var store: ChildStore<*, *, *>
+
+ protected fun connectStore(
+ store: Store,
+ 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(
+ val store: Store,
+ val changeMapper: (Action) -> IChange?
+ ) {
+ fun onCleared() {
+ store.onCleared()
+ }
+
+ fun dispatchAction(action: Action) {
+ changeMapper(action)?.let(store::changeState)
+ }
+ }
+
+}
diff --git a/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/core/MviViewModel.kt b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/core/MviViewModel.kt
new file mode 100644
index 0000000..71ef368
--- /dev/null
+++ b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/core/MviViewModel.kt
@@ -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(
+ 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)
+
+}
diff --git a/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/core/Store.kt b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/core/Store.kt
new file mode 100644
index 0000000..56eef64
--- /dev/null
+++ b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/core/Store.kt
@@ -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(
+ initialState: State
+) {
+
+ protected val currentState: State
+ get() = state.value
+
+ private val storeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+
+ private val effects = Channel(Channel.UNLIMITED)
+ private val state = MutableStateFlow(initialState)
+
+ private val childStores: MutableList> = 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
+
+ fun onCleared() {
+ storeScope.coroutineContext.cancel()
+ childStores.forEach(Store.ChildStore<*, *, *>::onCleared)
+ }
+
+ fun State.only(): Pair = this to null
+
+ fun Effect.only(): Pair = currentState to this
+
+ fun same(): Pair = currentState.only()
+
+ protected fun addChildStore(
+ store: Store,
+ changeMapper: (Change) -> ChildChange?,
+ stateMapper: (ChildState) -> State
+ ) {
+ childStores.add(ChildStore(store, changeMapper))
+
+ store
+ .observeState()
+ .onEach { state.value = stateMapper(it) }
+ .launchIn(storeScope)
+
+ }
+
+ protected open fun Flow.handleSideEffect(): Flow = emptyFlow()
+
+ protected abstract fun reduce(currentState: State, change: Change): Pair
+
+ private inner class ChildStore(
+ val store: Store,
+ val changeMapper: (Change) -> ChildChange?
+ ) {
+ fun onCleared() {
+ store.onCleared()
+ }
+
+ fun change(change: Change) {
+ changeMapper(change)?.let(store::changeState)
+ }
+ }
+
+}
diff --git a/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/di/ViewModelAssistedFactory.kt b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/di/ViewModelAssistedFactory.kt
new file mode 100644
index 0000000..d958ce0
--- /dev/null
+++ b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/di/ViewModelAssistedFactory.kt
@@ -0,0 +1,8 @@
+package ru.touchin.roboswag.mvi_arch.di
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+
+interface ViewModelAssistedFactory {
+ fun create(handle: SavedStateHandle): VM
+}
diff --git a/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/di/ViewModelFactory.kt b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/di/ViewModelFactory.kt
new file mode 100644
index 0000000..5ae4d27
--- /dev/null
+++ b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/di/ViewModelFactory.kt
@@ -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, ViewModelAssistedFactory>,
+ owner: SavedStateRegistryOwner,
+ arguments: Bundle
+) : AbstractSavedStateViewModelFactory(owner, arguments) {
+
+ @Suppress("UNCHECKED_CAST")
+ override fun create(key: String, modelClass: Class, handle: SavedStateHandle): T {
+ return viewModelMap[modelClass]?.create(handle) as? T ?: throw IllegalStateException("Unknown ViewModel class")
+ }
+}
diff --git a/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/di/ViewModelKey.kt b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/di/ViewModelKey.kt
new file mode 100644
index 0000000..672c4d0
--- /dev/null
+++ b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/di/ViewModelKey.kt
@@ -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)
diff --git a/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/marker/SideEffect.kt b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/marker/SideEffect.kt
new file mode 100644
index 0000000..7fea61c
--- /dev/null
+++ b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/marker/SideEffect.kt
@@ -0,0 +1,3 @@
+package ru.touchin.roboswag.mvi_arch.marker
+
+interface SideEffect
diff --git a/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/marker/StateChange.kt b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/marker/StateChange.kt
new file mode 100644
index 0000000..93f3378
--- /dev/null
+++ b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/marker/StateChange.kt
@@ -0,0 +1,4 @@
+package ru.touchin.roboswag.mvi_arch.marker
+
+interface StateChange {
+}
diff --git a/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/marker/ViewAction.kt b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/marker/ViewAction.kt
new file mode 100644
index 0000000..6e6d7cd
--- /dev/null
+++ b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/marker/ViewAction.kt
@@ -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
diff --git a/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/marker/ViewState.kt b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/marker/ViewState.kt
new file mode 100644
index 0000000..3d065e6
--- /dev/null
+++ b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/marker/ViewState.kt
@@ -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
diff --git a/mvi-arch/src/main/res/values/strings.xml b/mvi-arch/src/main/res/values/strings.xml
new file mode 100644
index 0000000..3c24c32
--- /dev/null
+++ b/mvi-arch/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ mvi-arch
+
diff --git a/navigation-base/build.gradle b/navigation-base/build.gradle
index e444e8a..5f1a068 100644
--- a/navigation-base/build.gradle
+++ b/navigation-base/build.gradle
@@ -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'
+ }
+ }
}
}
diff --git a/navigation-base/src/main/java/ru/touchin/roboswag/navigation_base/fragments/FragmentViewBindingDelegate.kt b/navigation-base/src/main/java/ru/touchin/roboswag/navigation_base/fragments/FragmentViewBindingDelegate.kt
new file mode 100644
index 0000000..6d7bee4
--- /dev/null
+++ b/navigation-base/src/main/java/ru/touchin/roboswag/navigation_base/fragments/FragmentViewBindingDelegate.kt
@@ -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(
+ val fragment: Fragment,
+ val viewBindingFactory: (View) -> T
+) : ReadOnlyProperty {
+ 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 Fragment.viewBinding(viewBindingFactory: (View) -> T) =
+ FragmentViewBindingDelegate(this, viewBindingFactory)
diff --git a/navigation-cicerone/README.md b/navigation-cicerone/README.md
new file mode 100644
index 0000000..bca41e1
--- /dev/null
+++ b/navigation-cicerone/README.md
@@ -0,0 +1,4 @@
+navigation-cicerone
+====
+
+TODO: rewrite dependencies
diff --git a/navigation-cicerone/build.gradle.kts b/navigation-cicerone/build.gradle.kts
new file mode 100644
index 0000000..16b2d76
--- /dev/null
+++ b/navigation-cicerone/build.gradle.kts
@@ -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)
+}
diff --git a/navigation-cicerone/src/main/AndroidManifest.xml b/navigation-cicerone/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..a6abf81
--- /dev/null
+++ b/navigation-cicerone/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/CiceroneTuner.kt b/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/CiceroneTuner.kt
new file mode 100644
index 0000000..2f2d1c9
--- /dev/null
+++ b/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/CiceroneTuner.kt
@@ -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
+ )
+ }
+
+}
diff --git a/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/flow/FlowFragment.kt b/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/flow/FlowFragment.kt
new file mode 100644
index 0000000..239c943
--- /dev/null
+++ b/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/flow/FlowFragment.kt
@@ -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
+}
diff --git a/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/flow/FlowNavigation.kt b/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/flow/FlowNavigation.kt
new file mode 100644
index 0000000..fa67fc7
--- /dev/null
+++ b/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/flow/FlowNavigation.kt
@@ -0,0 +1,6 @@
+package ru.touchin.roboswag.navigation_cicerone.flow
+
+import javax.inject.Qualifier
+
+@Qualifier
+annotation class FlowNavigation
diff --git a/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/flow/FlowNavigationModule.kt b/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/flow/FlowNavigationModule.kt
new file mode 100644
index 0000000..27864ac
--- /dev/null
+++ b/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/flow/FlowNavigationModule.kt
@@ -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 = Cicerone.create()
+
+ @Provides
+ @FlowNavigation
+ fun provideNavigatorHolder(@FlowNavigation cicerone: Cicerone): NavigatorHolder = cicerone.navigatorHolder
+
+ @Provides
+ @FlowNavigation
+ fun provideRouter(@FlowNavigation cicerone: Cicerone): Router = cicerone.router
+
+}
diff --git a/navigation-cicerone/src/main/res/layout/fragment_flow.xml b/navigation-cicerone/src/main/res/layout/fragment_flow.xml
new file mode 100644
index 0000000..b17fb3e
--- /dev/null
+++ b/navigation-cicerone/src/main/res/layout/fragment_flow.xml
@@ -0,0 +1,5 @@
+
+
diff --git a/navigation-cicerone/src/main/res/values/strings.xml b/navigation-cicerone/src/main/res/values/strings.xml
new file mode 100644
index 0000000..3c24c32
--- /dev/null
+++ b/navigation-cicerone/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ mvi-arch
+
diff --git a/pagination/README.md b/pagination/README.md
new file mode 100644
index 0000000..7ff06b0
--- /dev/null
+++ b/pagination/README.md
@@ -0,0 +1,4 @@
+pagination
+====
+
+TODO: rewrite dependencies
diff --git a/pagination/build.gradle.kts b/pagination/build.gradle.kts
new file mode 100644
index 0000000..53f07c6
--- /dev/null
+++ b/pagination/build.gradle.kts
@@ -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)
+}
diff --git a/pagination/src/main/AndroidManifest.xml b/pagination/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..49414e3
--- /dev/null
+++ b/pagination/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/pagination/src/main/java/ru/touchin/roboswag/pagination/PaginationAdapter.kt b/pagination/src/main/java/ru/touchin/roboswag/pagination/PaginationAdapter.kt
new file mode 100644
index 0000000..13b8d37
--- /dev/null
+++ b/pagination/src/main/java/ru/touchin/roboswag/pagination/PaginationAdapter.kt
@@ -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
+) : DelegationListAdapter(
+ object : DiffUtil.ItemCallback() {
+ 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, isPageProgress: Boolean) {
+ submitList(data + listOfNotNull(ProgressItem.takeIf { isPageProgress }))
+ }
+
+ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: List) {
+ super.onBindViewHolder(holder, position, payloads)
+ if (!fullData && position >= itemCount - 10) nextPageCallback.invoke()
+ }
+
+}
diff --git a/pagination/src/main/java/ru/touchin/roboswag/pagination/PaginationView.kt b/pagination/src/main/java/ru/touchin/roboswag/pagination/PaginationView.kt
new file mode 100644
index 0000000..7311245
--- /dev/null
+++ b/pagination/src/main/java/ru/touchin/roboswag/pagination/PaginationView.kt
@@ -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, false)
+ }
+ is Paginator.State.Refresh<*> -> {
+ adapter.update(state.data as List, false)
+ }
+ is Paginator.State.NewPageProgress<*> -> {
+ adapter.update(state.data as List, true)
+ }
+ is Paginator.State.FullData<*> -> {
+ adapter.update(state.data as List, false)
+ }
+ }
+ }
+ }
+
+}
diff --git a/pagination/src/main/java/ru/touchin/roboswag/pagination/Paginator.kt b/pagination/src/main/java/ru/touchin/roboswag/pagination/Paginator.kt
new file mode 100644
index 0000000..7081d8e
--- /dev/null
+++ b/pagination/src/main/java/ru/touchin/roboswag/pagination/Paginator.kt
@@ -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- (
+ private val showError: (Error) -> Unit,
+ private val loadPage: suspend (Int) -> List
-
+) : Store(State.Empty) {
+
+ sealed class Change : StateChange {
+ object Refresh : Change()
+ object Restart : Change()
+ object LoadMore : Change()
+ object Reset : Change()
+ data class NewPageLoaded(val pageNumber: Int, val items: List) : 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(val pageCount: Int = 0, val data: List) : State()
+ data class Refresh(val pageCount: Int, val data: List) : State()
+ data class NewPageProgress(val pageCount: Int, val data: List) : State()
+ data class FullData(val pageCount: Int, val data: List) : State()
+ }
+
+ sealed class Error {
+ object NewPageFailed : Error()
+ object RefreshFailed : Error()
+ }
+
+ override fun reduce(currentState: State, change: Change): Pair = 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.handleSideEffect(): Flow = 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))
+ }
+
+ }
+ }
+ }
+ }
+
+}
diff --git a/pagination/src/main/java/ru/touchin/roboswag/pagination/ProgressAdapterDelegate.kt b/pagination/src/main/java/ru/touchin/roboswag/pagination/ProgressAdapterDelegate.kt
new file mode 100644
index 0000000..093ddfe
--- /dev/null
+++ b/pagination/src/main/java/ru/touchin/roboswag/pagination/ProgressAdapterDelegate.kt
@@ -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() {
+
+ 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
+ ) = Unit
+}
diff --git a/pagination/src/main/java/ru/touchin/roboswag/pagination/ProgressItem.kt b/pagination/src/main/java/ru/touchin/roboswag/pagination/ProgressItem.kt
new file mode 100644
index 0000000..7e46cec
--- /dev/null
+++ b/pagination/src/main/java/ru/touchin/roboswag/pagination/ProgressItem.kt
@@ -0,0 +1,3 @@
+package ru.touchin.mvi_test.core_ui.pagination
+
+object ProgressItem
diff --git a/pagination/src/main/res/layout/item_progress.xml b/pagination/src/main/res/layout/item_progress.xml
new file mode 100644
index 0000000..726a55b
--- /dev/null
+++ b/pagination/src/main/res/layout/item_progress.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
diff --git a/pagination/src/main/res/layout/view_pagination.xml b/pagination/src/main/res/layout/view_pagination.xml
new file mode 100644
index 0000000..b022ded
--- /dev/null
+++ b/pagination/src/main/res/layout/view_pagination.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pagination/src/main/res/values/strings.xml b/pagination/src/main/res/values/strings.xml
new file mode 100644
index 0000000..16f2c81
--- /dev/null
+++ b/pagination/src/main/res/values/strings.xml
@@ -0,0 +1,5 @@
+
+
+ New page load error. Try again.
+ Refresh screen error. Try again.
+