From d565637407ce2ad4c662617e59bcddd284f83c8f Mon Sep 17 00:00:00 2001 From: Maxim Bachinsky Date: Sat, 27 Jun 2020 18:42:58 +0300 Subject: [PATCH] move to roboswag modules (mvi, pagination, cicerone, viewbinding) from test proj --- README.md | 4 +- encrypted-shared-prefs/README.md | 14 -- encrypted-shared-prefs/build.gradle | 24 --- .../src/main/AndroidManifest.xml | 1 - .../roboswag/CipherSharedPreferences.kt | 113 ------------- .../java/ru/touchin/roboswag/Extensions.kt | 10 -- .../ru/touchin/roboswag/PrefsCryptoUtils.kt | 125 --------------- .../.gitignore | 0 mvi-arch/README.md | 4 + mvi-arch/build.gradle.kts | 13 ++ mvi-arch/src/main/AndroidManifest.xml | 1 + .../roboswag/mvi_arch/core/MviFragment.kt | 150 ++++++++++++++++++ .../mvi_arch/core/MviStoreViewModel.kt | 82 ++++++++++ .../roboswag/mvi_arch/core/MviViewModel.kt | 46 ++++++ .../touchin/roboswag/mvi_arch/core/Store.kt | 106 +++++++++++++ .../mvi_arch/di/ViewModelAssistedFactory.kt | 8 + .../roboswag/mvi_arch/di/ViewModelFactory.kt | 19 +++ .../roboswag/mvi_arch/di/ViewModelKey.kt | 9 ++ .../roboswag/mvi_arch/marker/SideEffect.kt | 3 + .../roboswag/mvi_arch/marker/StateChange.kt | 4 + .../roboswag/mvi_arch/marker/ViewAction.kt | 22 +++ .../roboswag/mvi_arch/marker/ViewState.kt | 12 ++ mvi-arch/src/main/res/values/strings.xml | 3 + navigation-base/build.gradle | 19 +++ .../fragments/FragmentViewBindingDelegate.kt | 49 ++++++ navigation-cicerone/README.md | 4 + navigation-cicerone/build.gradle.kts | 10 ++ .../src/main/AndroidManifest.xml | 1 + .../navigation_cicerone/CiceroneTuner.kt | 44 +++++ .../navigation_cicerone/flow/FlowFragment.kt | 46 ++++++ .../flow/FlowNavigation.kt | 6 + .../flow/FlowNavigationModule.kt | 26 +++ .../src/main/res/layout/fragment_flow.xml | 5 + .../src/main/res/values/strings.xml | 3 + pagination/README.md | 4 + pagination/build.gradle.kts | 12 ++ pagination/src/main/AndroidManifest.xml | 1 + .../roboswag/pagination/PaginationAdapter.kt | 39 +++++ .../roboswag/pagination/PaginationView.kt | 76 +++++++++ .../touchin/roboswag/pagination/Paginator.kt | 128 +++++++++++++++ .../pagination/ProgressAdapterDelegate.kt | 22 +++ .../roboswag/pagination/ProgressItem.kt | 3 + .../src/main/res/layout/item_progress.xml | 12 ++ .../src/main/res/layout/view_pagination.xml | 33 ++++ pagination/src/main/res/values/strings.xml | 5 + 45 files changed, 1032 insertions(+), 289 deletions(-) delete mode 100644 encrypted-shared-prefs/README.md delete mode 100644 encrypted-shared-prefs/build.gradle delete mode 100644 encrypted-shared-prefs/src/main/AndroidManifest.xml delete mode 100644 encrypted-shared-prefs/src/main/java/ru/touchin/roboswag/CipherSharedPreferences.kt delete mode 100644 encrypted-shared-prefs/src/main/java/ru/touchin/roboswag/Extensions.kt delete mode 100644 encrypted-shared-prefs/src/main/java/ru/touchin/roboswag/PrefsCryptoUtils.kt rename {encrypted-shared-prefs => mvi-arch}/.gitignore (100%) create mode 100644 mvi-arch/README.md create mode 100644 mvi-arch/build.gradle.kts create mode 100644 mvi-arch/src/main/AndroidManifest.xml create mode 100644 mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/core/MviFragment.kt create mode 100644 mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/core/MviStoreViewModel.kt create mode 100644 mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/core/MviViewModel.kt create mode 100644 mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/core/Store.kt create mode 100644 mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/di/ViewModelAssistedFactory.kt create mode 100644 mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/di/ViewModelFactory.kt create mode 100644 mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/di/ViewModelKey.kt create mode 100644 mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/marker/SideEffect.kt create mode 100644 mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/marker/StateChange.kt create mode 100644 mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/marker/ViewAction.kt create mode 100644 mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/marker/ViewState.kt create mode 100644 mvi-arch/src/main/res/values/strings.xml create mode 100644 navigation-base/src/main/java/ru/touchin/roboswag/navigation_base/fragments/FragmentViewBindingDelegate.kt create mode 100644 navigation-cicerone/README.md create mode 100644 navigation-cicerone/build.gradle.kts create mode 100644 navigation-cicerone/src/main/AndroidManifest.xml create mode 100644 navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/CiceroneTuner.kt create mode 100644 navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/flow/FlowFragment.kt create mode 100644 navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/flow/FlowNavigation.kt create mode 100644 navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/flow/FlowNavigationModule.kt create mode 100644 navigation-cicerone/src/main/res/layout/fragment_flow.xml create mode 100644 navigation-cicerone/src/main/res/values/strings.xml create mode 100644 pagination/README.md create mode 100644 pagination/build.gradle.kts create mode 100644 pagination/src/main/AndroidManifest.xml create mode 100644 pagination/src/main/java/ru/touchin/roboswag/pagination/PaginationAdapter.kt create mode 100644 pagination/src/main/java/ru/touchin/roboswag/pagination/PaginationView.kt create mode 100644 pagination/src/main/java/ru/touchin/roboswag/pagination/Paginator.kt create mode 100644 pagination/src/main/java/ru/touchin/roboswag/pagination/ProgressAdapterDelegate.kt create mode 100644 pagination/src/main/java/ru/touchin/roboswag/pagination/ProgressItem.kt create mode 100644 pagination/src/main/res/layout/item_progress.xml create mode 100644 pagination/src/main/res/layout/view_pagination.xml create mode 100644 pagination/src/main/res/values/strings.xml 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. +