├── .gitignore ├── README.md ├── app ├── build.gradle.kts ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── com │ │ └── begoml │ │ └── uistatedelegate │ │ ├── AppActivity.kt │ │ ├── AppApplication.kt │ │ ├── common │ │ └── flow.kt │ │ ├── di │ │ ├── AppComponent.kt │ │ ├── AppProvider.kt │ │ ├── CoreModule.kt │ │ └── DelegateModule.kt │ │ ├── features │ │ ├── common │ │ │ └── ViewModelExt.kt │ │ ├── delegates │ │ │ ├── ToolbarDelegate.kt │ │ │ └── payments │ │ │ │ ├── PaymentAnalytics.kt │ │ │ │ └── PaymentDelegate.kt │ │ ├── home │ │ │ ├── HomeScreen.kt │ │ │ ├── HomeViewModel.kt │ │ │ └── models │ │ │ │ └── DashboardUi.kt │ │ ├── login │ │ │ ├── LoginScreen.kt │ │ │ └── LoginViewModel.kt │ │ ├── shop │ │ │ └── ShopViewModel.kt │ │ └── user │ │ │ ├── UserScreen.kt │ │ │ └── UserViewModel.kt │ │ ├── forgotpassword │ │ ├── ForgotPasswordActivity.kt │ │ ├── ForgotPasswordFragment.kt │ │ └── ForgotPasswordViewModel.kt │ │ ├── navigation │ │ ├── NavBackStackEntryExt.kt │ │ ├── NavHostControllerExt.kt │ │ └── NavigationDestination.kt │ │ ├── state │ │ ├── CombinedStateDelegate.kt │ │ ├── InternalStateDelegate.kt │ │ └── UiStateDelegate.kt │ │ ├── ui │ │ ├── components │ │ │ └── AppTopBar.kt │ │ └── theme │ │ │ ├── Color.kt │ │ │ ├── Shape.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ │ └── uistate │ │ ├── UiStateDelegateExt.kt │ │ ├── UiStateDiffRender.kt │ │ └── UiStateExt.kt │ └── res │ ├── drawable-v24 │ └── ic_launcher_foreground.xml │ ├── drawable │ └── ic_launcher_background.xml │ ├── layout │ ├── activity_forgot_password.xml │ └── fragment_forgot_password.xml │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-mdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xxhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xxxhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ └── values │ ├── colors.xml │ ├── strings.xml │ └── themes.xml ├── build.gradle.kts ├── core ├── .gitignore ├── build.gradle.kts ├── consumer-rules.pro ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ └── java │ └── com │ └── begoml │ └── core │ ├── ApiService.kt │ ├── AuthRepository.kt │ ├── PaymentHistoryRepository.kt │ ├── PaymentRepository.kt │ ├── UserRepository.kt │ └── home │ ├── DashboardRepository.kt │ └── models │ └── Dashboard.kt ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Android-UIState-Delegate 2 |
3 | Architecture with MVVM and UIState
4 | 5 | [Medium](https://medium.com/@MrAndroid/android-architecture-with-mvvm-and-uistate-f29aa5494465) 6 | -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | 3 | plugins { 4 | kotlin("android") 5 | id("com.android.application") 6 | id("kotlin-kapt") 7 | } 8 | 9 | android { 10 | namespace = "com.begoml.uistatedelegate" 11 | compileSdk = 34 12 | 13 | defaultConfig { 14 | applicationId = "com.begoml.uistatedelegate" 15 | minSdk = 28 16 | targetSdk = 34 17 | versionCode = 1 18 | versionName = "1.0" 19 | 20 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 21 | } 22 | 23 | buildTypes { 24 | getByName("release") { 25 | isMinifyEnabled = true 26 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") 27 | } 28 | 29 | getByName("debug") { 30 | isMinifyEnabled = false 31 | } 32 | } 33 | 34 | compileOptions { 35 | sourceCompatibility = JavaVersion.VERSION_17 36 | targetCompatibility = JavaVersion.VERSION_17 37 | } 38 | 39 | kotlinOptions { 40 | jvmTarget = JavaVersion.VERSION_17.toString() 41 | freeCompilerArgs = listOf("-Xcontext-receivers") 42 | } 43 | 44 | buildFeatures { 45 | compose = true 46 | viewBinding = true 47 | } 48 | 49 | composeOptions { 50 | kotlinCompilerExtensionVersion = "1.5.3" 51 | } 52 | 53 | lint.abortOnError = false 54 | } 55 | 56 | dependencies { 57 | implementation(project(":core")) 58 | 59 | implementation("androidx.appcompat:appcompat:1.6.1") 60 | implementation("androidx.fragment:fragment:1.6.1") 61 | implementation("androidx.fragment:fragment-ktx:1.6.1") 62 | 63 | implementation("androidx.activity:activity-compose:1.8.0") 64 | 65 | implementation(platform("androidx.compose:compose-bom:2023.10.00")) 66 | 67 | implementation("androidx.compose.material:material") 68 | implementation("androidx.compose.foundation:foundation") 69 | implementation("androidx.compose.ui:ui") 70 | 71 | implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.2") 72 | 73 | implementation("com.google.dagger:dagger:2.48") 74 | kapt("com.google.dagger:dagger-compiler:2.48") 75 | 76 | implementation("androidx.navigation:navigation-compose:2.7.4") 77 | } 78 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle.kts. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 17 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/AppActivity.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.compose.material.MaterialTheme 7 | import androidx.compose.material.Surface 8 | import androidx.compose.runtime.CompositionLocalProvider 9 | import androidx.navigation.compose.NavHost 10 | import androidx.navigation.compose.composable 11 | import androidx.navigation.compose.rememberNavController 12 | import com.begoml.core.AuthRepository 13 | import com.begoml.uistatedelegate.di.LocalAppProvider 14 | import com.begoml.uistatedelegate.features.common.daggerViewModel 15 | import com.begoml.uistatedelegate.features.home.HomeScreen 16 | import com.begoml.uistatedelegate.features.home.HomeViewModel 17 | import com.begoml.uistatedelegate.features.login.LoginScreen 18 | import com.begoml.uistatedelegate.features.login.LoginViewModel 19 | import com.begoml.uistatedelegate.features.user.UserScreen 20 | import com.begoml.uistatedelegate.features.user.UserViewModel 21 | import com.begoml.uistatedelegate.navigation.NavigationDestination 22 | import com.begoml.uistatedelegate.ui.theme.AppTheme 23 | 24 | class AppActivity : ComponentActivity() { 25 | 26 | override fun onCreate(savedInstanceState: Bundle?) { 27 | super.onCreate(savedInstanceState) 28 | setContent { 29 | AppTheme { 30 | Surface(color = MaterialTheme.colors.background) { 31 | CompositionLocalProvider( 32 | LocalAppProvider provides application.appProvider, 33 | ) { 34 | val navController = rememberNavController() 35 | val provider = LocalAppProvider.current 36 | 37 | NavHost(navController, startDestination = NavigationDestination.Login.destination) { 38 | composable(NavigationDestination.Login.destination) { 39 | val viewModel = daggerViewModel { 40 | LoginViewModel( 41 | authRepository = provider.provideAuthRepository() 42 | ) 43 | } 44 | LoginScreen( 45 | navController = navController, 46 | viewModel = viewModel, 47 | ) 48 | } 49 | 50 | composable(NavigationDestination.Home.destination) { 51 | val viewModel = daggerViewModel { 52 | HomeViewModel( 53 | dashboardRepository = provider.provideDashboardRepository(), 54 | toolbarDelegate = provider.provideToolbarDelegate(), 55 | ) 56 | } 57 | HomeScreen( 58 | navController = navController, 59 | viewModel = viewModel, 60 | ) 61 | } 62 | composable(NavigationDestination.User.destination) { 63 | val viewModel = daggerViewModel { 64 | UserViewModel( 65 | toolbarDelegate = provider.provideToolbarDelegate(), 66 | ) 67 | } 68 | UserScreen(viewModel = viewModel) 69 | } 70 | } 71 | } 72 | } 73 | } 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/AppApplication.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate 2 | 3 | import android.app.Application 4 | import com.begoml.uistatedelegate.di.AppProvider 5 | import com.begoml.uistatedelegate.di.DaggerAppComponent 6 | 7 | class AppApplication : Application() { 8 | 9 | lateinit var appProvider: AppProvider 10 | private set 11 | 12 | override fun onCreate() { 13 | super.onCreate() 14 | 15 | appProvider = DaggerAppComponent.builder() 16 | .build() 17 | } 18 | } 19 | 20 | 21 | val Application.appProvider: AppProvider 22 | get() = (this as AppApplication).appProvider 23 | -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/common/flow.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.common 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.CoroutineStart 5 | import kotlinx.coroutines.Job 6 | import kotlinx.coroutines.flow.Flow 7 | import kotlinx.coroutines.launch 8 | import kotlin.coroutines.CoroutineContext 9 | import kotlin.coroutines.EmptyCoroutineContext 10 | 11 | inline fun Flow.collectIn( 12 | coroutineScope: CoroutineScope, 13 | context: CoroutineContext = EmptyCoroutineContext, 14 | start: CoroutineStart = CoroutineStart.DEFAULT, 15 | crossinline collector: suspend (T) -> Unit 16 | ): Job = coroutineScope.launch( 17 | context = context, 18 | start = start 19 | ) { 20 | this@collectIn.collect { 21 | collector(it) 22 | } 23 | } -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/di/AppComponent.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.di 2 | 3 | import dagger.Component 4 | import javax.inject.Singleton 5 | 6 | @Singleton 7 | @Component( 8 | modules = [ 9 | CoreModule::class, 10 | DelegateModule::class, 11 | ] 12 | ) 13 | interface AppComponent : AppProvider 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/di/AppProvider.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.di 2 | 3 | import androidx.compose.runtime.compositionLocalOf 4 | import com.begoml.core.AuthRepository 5 | import com.begoml.core.home.DashboardRepository 6 | import com.begoml.uistatedelegate.features.delegates.ToolbarDelegate 7 | 8 | interface AppProvider { 9 | 10 | fun provideAuthRepository(): AuthRepository 11 | 12 | fun provideDashboardRepository(): DashboardRepository 13 | 14 | fun provideToolbarDelegate(): ToolbarDelegate 15 | } 16 | 17 | val LocalAppProvider = compositionLocalOf { error("No app provider found!") } 18 | -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/di/CoreModule.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.di 2 | 3 | import com.begoml.core.ApiService 4 | import com.begoml.core.AuthRepository 5 | import com.begoml.core.home.DashboardRepository 6 | import dagger.Binds 7 | import dagger.Module 8 | import dagger.Provides 9 | import dagger.Reusable 10 | import javax.inject.Singleton 11 | 12 | @Module 13 | class CoreModule { 14 | 15 | @Singleton 16 | @Provides 17 | fun provideApiService() = ApiService() 18 | 19 | @Provides 20 | fun provideAuthRepository(apiService: ApiService) = AuthRepository(apiService) 21 | 22 | @Provides 23 | @Reusable 24 | fun provideDashboardRepository(apiService: ApiService) = DashboardRepository(apiService) 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/di/DelegateModule.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.di 2 | 3 | import com.begoml.core.PaymentHistoryRepository 4 | import com.begoml.core.PaymentRepository 5 | import com.begoml.core.UserRepository 6 | import com.begoml.uistatedelegate.features.delegates.ToolbarDelegate 7 | import com.begoml.uistatedelegate.features.delegates.ToolbarDelegateImpl 8 | import com.begoml.uistatedelegate.features.delegates.payments.PaymentAnalytics 9 | import com.begoml.uistatedelegate.features.delegates.payments.PaymentDelegate 10 | import com.begoml.uistatedelegate.features.delegates.payments.PaymentDelegateImpl 11 | import dagger.Module 12 | import dagger.Provides 13 | import kotlinx.coroutines.CoroutineScope 14 | import kotlinx.coroutines.Dispatchers 15 | import javax.inject.Singleton 16 | 17 | @Module 18 | class DelegateModule { 19 | 20 | private val coroutineScope by lazy { CoroutineScope(Dispatchers.IO) } 21 | 22 | @Singleton 23 | @Provides 24 | fun provideToolbarDelegate( 25 | userRepository: UserRepository, 26 | ): ToolbarDelegate = ToolbarDelegateImpl( 27 | coroutineScope = coroutineScope, 28 | userRepository = userRepository, 29 | ) 30 | 31 | @Provides 32 | fun providePaymentDelegate( 33 | paymentAnalytics: PaymentAnalytics, 34 | paymentHistoryRepository: PaymentHistoryRepository, 35 | paymentRepository: PaymentRepository, 36 | ): PaymentDelegate = PaymentDelegateImpl( 37 | paymentAnalytics = paymentAnalytics, 38 | paymentHistoryRepository = paymentHistoryRepository, 39 | paymentRepository = paymentRepository, 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/features/common/ViewModelExt.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.features.common 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.ViewModelProvider 6 | import androidx.lifecycle.viewmodel.compose.viewModel 7 | 8 | @Composable 9 | inline fun daggerViewModel( 10 | key: String? = null, 11 | crossinline viewModelInstanceCreator: () -> T 12 | ): T = viewModel( 13 | modelClass = T::class.java, 14 | key = key, 15 | factory = object : ViewModelProvider.Factory { 16 | override fun create(modelClass: Class): T { 17 | return viewModelInstanceCreator() as T 18 | } 19 | } 20 | ) 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/features/delegates/ToolbarDelegate.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.features.delegates 2 | 3 | import androidx.annotation.FloatRange 4 | import com.begoml.core.UserRepository 5 | import com.begoml.uistatedelegate.state.UiStateDelegate 6 | import com.begoml.uistatedelegate.state.UiStateDelegateImpl 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.delay 10 | import kotlinx.coroutines.flow.StateFlow 11 | import kotlinx.coroutines.flow.catch 12 | import kotlinx.coroutines.flow.flowOn 13 | import kotlinx.coroutines.flow.launchIn 14 | import kotlinx.coroutines.flow.onEach 15 | import kotlinx.coroutines.launch 16 | import javax.inject.Inject 17 | 18 | interface ToolbarDelegate { 19 | 20 | val toolbarUiState: StateFlow 21 | } 22 | 23 | data class ToolbarUiState( 24 | val title: String = "", 25 | 26 | @FloatRange(from = 0.0, to = 100.0) 27 | val levelProgress: Float = 0f, 28 | 29 | val levelLabel: String = "", 30 | ) 31 | 32 | class ToolbarDelegateImpl @Inject constructor( 33 | coroutineScope: CoroutineScope, 34 | userRepository: UserRepository, 35 | ) : ToolbarDelegate, 36 | UiStateDelegate by UiStateDelegateImpl(ToolbarUiState()) { 37 | 38 | override val toolbarUiState: StateFlow 39 | get() = uiStateFlow 40 | 41 | init { 42 | coroutineScope.launch { 43 | delay(500L) 44 | updateUiState { state -> 45 | state.copy( 46 | title = "Title", 47 | levelLabel = "Level:", 48 | ) 49 | } 50 | } 51 | 52 | userRepository.getUserLevelFlow() 53 | .flowOn(Dispatchers.IO) 54 | .onEach { levelProgress -> 55 | updateUiState { state -> state.copy(levelProgress = levelProgress) } 56 | } 57 | .catch { throwable -> throwable.printStackTrace() } 58 | .launchIn(coroutineScope) 59 | } 60 | 61 | } -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/features/delegates/payments/PaymentAnalytics.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.features.delegates.payments 2 | 3 | import javax.inject.Inject 4 | 5 | class PaymentAnalytics @Inject constructor() { 6 | 7 | suspend fun trackStartPaymentEvent() {} 8 | 9 | suspend fun trackFinishPaymentEvent() {} 10 | } -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/features/delegates/payments/PaymentDelegate.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.features.delegates.payments 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.begoml.core.PaymentHistoryRepository 6 | import com.begoml.core.PaymentRepository 7 | import kotlinx.coroutines.launch 8 | import javax.inject.Inject 9 | 10 | interface PaymentDelegate { 11 | 12 | context(ViewModel) 13 | fun pay(productId: String) 14 | 15 | suspend fun purchaseProduct(productId: String) 16 | } 17 | 18 | class PaymentDelegateImpl @Inject constructor( 19 | private val paymentRepository: PaymentRepository, 20 | private val paymentHistoryRepository: PaymentHistoryRepository, 21 | private val paymentAnalytics: PaymentAnalytics, 22 | ) : PaymentDelegate { 23 | 24 | context(ViewModel) 25 | override fun pay(productId: String) { 26 | viewModelScope.launch { 27 | purchaseProduct(productId) 28 | } 29 | } 30 | 31 | override suspend fun purchaseProduct(productId: String) { 32 | paymentAnalytics.trackStartPaymentEvent() 33 | 34 | paymentRepository.pay(productId) 35 | 36 | paymentAnalytics.trackFinishPaymentEvent() 37 | 38 | paymentHistoryRepository.refresh() 39 | } 40 | } -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/features/home/HomeScreen.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.features.home 2 | 3 | import android.content.Intent 4 | import com.begoml.uistatedelegate.features.home.HomeViewModel.Event 5 | import androidx.compose.foundation.background 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.material.Button 11 | import androidx.compose.material.MaterialTheme 12 | import androidx.compose.material.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.collectAsState 15 | import androidx.compose.runtime.getValue 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.platform.LocalContext 18 | import androidx.compose.ui.unit.dp 19 | import androidx.navigation.NavController 20 | import com.begoml.uistatedelegate.forgotpassword.ForgotPasswordActivity 21 | import com.begoml.uistatedelegate.navigation.NavigationDestination 22 | import com.begoml.uistatedelegate.ui.components.AppTopBar 23 | import com.begoml.uistatedelegate.uistate.CollectEventEffect 24 | 25 | @Composable 26 | fun HomeScreen( 27 | navController: NavController, 28 | viewModel: HomeViewModel, 29 | ) { 30 | val context = LocalContext.current 31 | 32 | viewModel.CollectEventEffect { event -> 33 | return@CollectEventEffect when (event) { 34 | Event.StartForgotPasswordFeature -> { 35 | context.startActivity(Intent(context, ForgotPasswordActivity::class.java)) 36 | } 37 | 38 | Event.StartUserFeature -> { 39 | navController.navigate(NavigationDestination.User.destination) 40 | } 41 | } 42 | } 43 | 44 | val uiState by viewModel.uiStateFlow.collectAsState() 45 | val toolbarUiState by viewModel.toolbarUiState.collectAsState() 46 | 47 | Column( 48 | modifier = Modifier 49 | .background(MaterialTheme.colors.background) 50 | .fillMaxSize() 51 | .padding(horizontal = 16.dp) 52 | ) { 53 | AppTopBar( 54 | modifier = Modifier.fillMaxWidth(), 55 | state = toolbarUiState, 56 | ) 57 | Text( 58 | modifier = Modifier.padding(top = 24.dp), 59 | text = "Home screen" 60 | ) 61 | Button(onClick = viewModel::onForgotPasswordClick) { 62 | Text(text = "Go To Forgot Password Flow") 63 | } 64 | Button(onClick = viewModel::onUserClick) { 65 | Text(text = "Go To User Flow") 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/features/home/HomeViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.features.home 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.begoml.core.home.DashboardRepository 6 | import com.begoml.uistatedelegate.features.home.HomeViewModel.State 7 | import com.begoml.uistatedelegate.features.home.HomeViewModel.UiState 8 | import com.begoml.uistatedelegate.features.home.HomeViewModel.Event 9 | import com.begoml.uistatedelegate.features.home.models.DashboardUi 10 | import com.begoml.uistatedelegate.features.home.models.toDashboardUi 11 | import com.begoml.uistatedelegate.features.delegates.ToolbarDelegate 12 | import com.begoml.uistatedelegate.state.CombinedStateDelegate 13 | import com.begoml.uistatedelegate.state.CombinedStateDelegateImpl 14 | import kotlinx.coroutines.CoroutineExceptionHandler 15 | import kotlinx.coroutines.launch 16 | 17 | class HomeViewModel( 18 | private val dashboardRepository: DashboardRepository, 19 | private val toolbarDelegate: ToolbarDelegate, 20 | ) : ViewModel(), 21 | ToolbarDelegate by toolbarDelegate, 22 | CombinedStateDelegate by CombinedStateDelegateImpl( 23 | initialState = State(), 24 | initialUiState = UiState(), 25 | ) { 26 | 27 | data class UiState( 28 | val isLoading: Boolean = false, 29 | val items: List = emptyList(), 30 | 31 | val filter: String = "", 32 | ) 33 | 34 | data class State( 35 | val fullItems: List = emptyList(), 36 | ) 37 | 38 | sealed interface Event { 39 | object StartForgotPasswordFeature : Event 40 | object StartUserFeature : Event 41 | } 42 | 43 | init { 44 | collectUpdateUiState(viewModelScope) { state, uiState -> 45 | val newItems = if (uiState.filter.isBlank()) { 46 | state.fullItems 47 | } else { 48 | state.fullItems.filter { item -> item.title.contains(uiState.filter) } 49 | } 50 | uiState.copy(items = newItems) 51 | } 52 | 53 | viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> 54 | throwable.printStackTrace() 55 | }) { 56 | updateUiState { uiState, _ -> uiState.copy(isLoading = true) } 57 | val items = runCatching { dashboardRepository.getHomeItems() } 58 | .getOrDefault(emptyList()) 59 | 60 | val uiItems = items.map { item -> item.toDashboardUi() } 61 | 62 | updateInternalState { state -> state.copy(fullItems = uiItems) } 63 | }.invokeOnCompletion { 64 | asyncUpdateUiState(viewModelScope) { uiState -> uiState.copy(isLoading = false) } 65 | } 66 | } 67 | 68 | fun onForgotPasswordClick() { 69 | viewModelScope.launch { sendEvent(Event.StartForgotPasswordFeature) } 70 | } 71 | 72 | fun onUserClick() { 73 | viewModelScope.launch { sendEvent(Event.StartUserFeature) } 74 | } 75 | } -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/features/home/models/DashboardUi.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.features.home.models 2 | 3 | import com.begoml.core.home.models.Dashboard 4 | 5 | data class DashboardUi( 6 | val title: String, 7 | val description: String, 8 | ) 9 | 10 | fun Dashboard.toDashboardUi() = DashboardUi( 11 | title = this.title, 12 | description = this.description, 13 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/features/login/LoginScreen.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.features.login 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.material.* 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.LaunchedEffect 8 | import androidx.compose.runtime.getValue 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.platform.LocalLifecycleOwner 12 | import androidx.compose.ui.unit.dp 13 | import androidx.lifecycle.LifecycleOwner 14 | import androidx.navigation.NavController 15 | import com.begoml.uistatedelegate.navigation.navigateSingleTopTo 16 | import com.begoml.uistatedelegate.navigation.NavigationDestination 17 | import com.begoml.uistatedelegate.uistate.collectEvent 18 | import com.begoml.uistatedelegate.uistate.collectWithLifecycle 19 | 20 | @Composable 21 | fun LoginScreen( 22 | navController: NavController, 23 | viewModel: LoginViewModel, 24 | lifecycle: LifecycleOwner = LocalLifecycleOwner.current 25 | ) { 26 | val uiState by viewModel.collectWithLifecycle() 27 | 28 | LaunchedEffect(key1 = Unit) { 29 | viewModel.collectEvent(lifecycle) { event -> 30 | when (event) { 31 | LoginViewModel.Event.GoToHome -> { 32 | navController.navigateSingleTopTo(NavigationDestination.Home.destination) 33 | } 34 | } 35 | } 36 | } 37 | 38 | Column( 39 | modifier = Modifier 40 | .background(MaterialTheme.colors.background) 41 | .fillMaxSize() 42 | .padding(horizontal = 16.dp) 43 | ) { 44 | Text( 45 | modifier = Modifier.padding(top = 24.dp), 46 | text = uiState.title 47 | ) 48 | if (uiState.isLoading) { 49 | Box(modifier = Modifier.fillMaxWidth()) { 50 | CircularProgressIndicator( 51 | modifier = Modifier 52 | .size(24.dp) 53 | .align(Alignment.Center), 54 | strokeWidth = 2.dp, 55 | color = MaterialTheme.colors.primary, 56 | ) 57 | } 58 | } 59 | TextField( 60 | modifier = Modifier 61 | .padding(top = 24.dp) 62 | .padding(horizontal = 16.dp) 63 | .fillMaxWidth(), 64 | value = uiState.login, 65 | onValueChange = viewModel::onLoginChange, 66 | enabled = uiState.isLoading.not(), 67 | ) 68 | TextField( 69 | modifier = Modifier 70 | .padding(top = 8.dp) 71 | .padding(horizontal = 16.dp) 72 | .fillMaxWidth(), 73 | value = uiState.password, 74 | onValueChange = viewModel::onPasswordChange, 75 | enabled = uiState.isLoading.not(), 76 | ) 77 | Button( 78 | modifier = Modifier 79 | .padding(top = 24.dp) 80 | .fillMaxWidth(), 81 | onClick = viewModel::onLoginClick, 82 | enabled = uiState.isLoading.not(), 83 | ) { 84 | Text("Login") 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/features/login/LoginViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.features.login 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.begoml.core.AuthRepository 6 | import com.begoml.uistatedelegate.features.login.LoginViewModel.Event 7 | import com.begoml.uistatedelegate.features.login.LoginViewModel.UiState 8 | import com.begoml.uistatedelegate.state.UiStateDelegate 9 | import com.begoml.uistatedelegate.state.UiStateDelegateImpl 10 | import kotlinx.coroutines.launch 11 | 12 | class LoginViewModel( 13 | private val authRepository: AuthRepository, 14 | ) : ViewModel(), UiStateDelegate by UiStateDelegateImpl(UiState()) { 15 | 16 | data class UiState( 17 | val isLoading: Boolean = false, 18 | val title: String = "", 19 | val login: String = "", 20 | val password: String = "", 21 | ) 22 | 23 | sealed interface Event { 24 | object GoToHome : Event 25 | } 26 | 27 | init { 28 | viewModelScope.launch { 29 | updateUiState { state -> 30 | state.copy( 31 | title = "Login screen" 32 | ) 33 | } 34 | } 35 | } 36 | 37 | fun onLoginChange(login: String) { 38 | asyncUpdateUiState(viewModelScope) { state -> state.copy(login = login) } 39 | } 40 | 41 | fun onPasswordChange(password: String) { 42 | asyncUpdateUiState(viewModelScope) { state -> state.copy(password = password) } 43 | } 44 | 45 | fun onLoginClick() { 46 | viewModelScope.launch { 47 | updateUiState { state -> state.copy(isLoading = true) } 48 | authRepository.login(login = uiState.login, password = uiState.password) 49 | sendEvent(Event.GoToHome) 50 | }.invokeOnCompletion { asyncUpdateUiState(viewModelScope) { state -> state.copy(isLoading = false) } } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/features/shop/ShopViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.features.shop 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.begoml.uistatedelegate.features.delegates.payments.PaymentDelegate 6 | import kotlinx.coroutines.launch 7 | import com.begoml.uistatedelegate.features.shop.ShopViewModel.UiState 8 | import com.begoml.uistatedelegate.state.UiStateDelegate 9 | import com.begoml.uistatedelegate.state.UiStateDelegateImpl 10 | import kotlinx.coroutines.CoroutineExceptionHandler 11 | 12 | class ShopViewModel( 13 | paymentDelegate: PaymentDelegate 14 | ) : ViewModel(), 15 | PaymentDelegate by paymentDelegate, 16 | UiStateDelegate by UiStateDelegateImpl(UiState()) { 17 | 18 | data class UiState( 19 | val isLoading: Boolean = false, 20 | ) 21 | 22 | private fun purchase(productId: String) { 23 | viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> 24 | throwable.printStackTrace() 25 | }) { 26 | updateUiState { state -> state.copy(isLoading = true) } 27 | purchaseProduct(productId) 28 | }.invokeOnCompletion { asyncUpdateUiState(viewModelScope) { state -> state.copy(isLoading = false) } } 29 | } 30 | } -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/features/user/UserScreen.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.features.user 2 | 3 | import androidx.compose.foundation.layout.fillMaxWidth 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.collectAsState 7 | import androidx.compose.runtime.getValue 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.unit.dp 10 | import com.begoml.uistatedelegate.ui.components.AppTopBar 11 | 12 | @Composable 13 | fun UserScreen( 14 | viewModel: UserViewModel, 15 | ) { 16 | val toolbarUiState by viewModel.toolbarUiState.collectAsState() 17 | 18 | AppTopBar( 19 | modifier = Modifier 20 | .fillMaxWidth() 21 | .padding(horizontal = 16.dp), 22 | state = toolbarUiState, 23 | ) 24 | } -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/features/user/UserViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.features.user 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.begoml.uistatedelegate.features.delegates.ToolbarDelegate 5 | 6 | class UserViewModel( 7 | private val toolbarDelegate: ToolbarDelegate, 8 | ) : ToolbarDelegate by toolbarDelegate, ViewModel() -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/forgotpassword/ForgotPasswordActivity.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.forgotpassword 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import com.begoml.uistatedelegate.R 6 | 7 | class ForgotPasswordActivity : AppCompatActivity() { 8 | 9 | override fun onCreate(savedInstanceState: Bundle?) { 10 | super.onCreate(savedInstanceState) 11 | setContentView(R.layout.activity_forgot_password) 12 | supportFragmentManager.beginTransaction().apply { 13 | add(R.id.appContainerView, ForgotPasswordFragment()) 14 | commit() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/forgotpassword/ForgotPasswordFragment.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.forgotpassword 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import androidx.core.view.isVisible 6 | import androidx.core.widget.doAfterTextChanged 7 | import androidx.fragment.app.Fragment 8 | import androidx.fragment.app.viewModels 9 | import com.begoml.uistatedelegate.AppApplication 10 | import com.begoml.uistatedelegate.R 11 | import com.begoml.uistatedelegate.databinding.FragmentForgotPasswordBinding 12 | import com.begoml.uistatedelegate.forgotpassword.ForgotPasswordViewModel.UiState 13 | import com.begoml.uistatedelegate.uistate.collectEvent 14 | import com.begoml.uistatedelegate.uistate.render 15 | import com.begoml.uistatedelegate.uistate.uiStateDiffRender 16 | 17 | class ForgotPasswordFragment : Fragment(R.layout.fragment_forgot_password) { 18 | 19 | private var _binding: FragmentForgotPasswordBinding? = null 20 | 21 | private val binding get() = _binding!! 22 | 23 | private val viewModel: ForgotPasswordViewModel by viewModels { 24 | ForgotPasswordViewModelFactory( 25 | authRepository = (requireContext().applicationContext as AppApplication).appProvider.provideAuthRepository() 26 | ) 27 | } 28 | 29 | 30 | private val render = uiStateDiffRender { 31 | UiState::isLoading { isLoading -> 32 | with(binding) { 33 | progress.isVisible = isLoading 34 | button.isEnabled = isLoading.not() 35 | loginInputFiled.isEnabled = isLoading.not() 36 | } 37 | } 38 | 39 | UiState::title { title -> 40 | binding.title.text = title 41 | } 42 | 43 | UiState::login { login -> 44 | binding.loginInputFiled.apply { 45 | setText(login) 46 | setSelection(login.length) 47 | } 48 | } 49 | } 50 | 51 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 52 | super.onViewCreated(view, savedInstanceState) 53 | _binding = FragmentForgotPasswordBinding.bind(view) 54 | 55 | with(binding) { 56 | button.setOnClickListener { viewModel.onForgotPasswordClick() } 57 | loginInputFiled.doAfterTextChanged(viewModel::onLoginTextChanged) 58 | } 59 | 60 | with(viewModel) { 61 | render( 62 | lifecycleOwner = viewLifecycleOwner, 63 | render = render 64 | ) 65 | collectEvent(lifecycle) { event -> 66 | return@collectEvent when (event) { 67 | ForgotPasswordViewModel.Event.FinishFlow -> requireActivity().finish() 68 | } 69 | } 70 | } 71 | } 72 | 73 | override fun onDestroyView() { 74 | _binding = null 75 | super.onDestroyView() 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/forgotpassword/ForgotPasswordViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.forgotpassword 2 | 3 | import android.text.Editable 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.ViewModelProvider 6 | import androidx.lifecycle.viewModelScope 7 | import com.begoml.core.AuthRepository 8 | import com.begoml.uistatedelegate.forgotpassword.ForgotPasswordViewModel.Event 9 | import com.begoml.uistatedelegate.forgotpassword.ForgotPasswordViewModel.UiState 10 | import com.begoml.uistatedelegate.state.UiStateDelegate 11 | import com.begoml.uistatedelegate.state.UiStateDelegateImpl 12 | import kotlinx.coroutines.launch 13 | 14 | class ForgotPasswordViewModelFactory( 15 | private val authRepository: AuthRepository, 16 | ) : ViewModelProvider.Factory { 17 | @Suppress("UNCHECKED_CAST") 18 | override fun create(modelClass: Class): T { 19 | return ForgotPasswordViewModel(authRepository = authRepository) as T 20 | } 21 | } 22 | 23 | class ForgotPasswordViewModel( 24 | private val authRepository: AuthRepository, 25 | ) : ViewModel(), 26 | UiStateDelegate by UiStateDelegateImpl(UiState()) { 27 | 28 | data class UiState( 29 | val isLoading: Boolean = false, 30 | val title: String = "", 31 | val login: String = "", 32 | ) 33 | 34 | sealed interface Event { 35 | object FinishFlow : Event 36 | } 37 | 38 | init { 39 | viewModelScope.launch { 40 | updateUiState { state -> state.copy(title = "Forgot Password") } 41 | } 42 | } 43 | 44 | fun onLoginTextChanged(value: Editable?) { 45 | asyncUpdateUiState(viewModelScope) { state -> state.copy(login = value.toString()) } 46 | } 47 | 48 | fun onForgotPasswordClick() { 49 | viewModelScope.launch { 50 | updateUiState { state -> state.copy(isLoading = true) } 51 | authRepository.forgotPassword(uiState.login) 52 | sendEvent(Event.FinishFlow) 53 | }.invokeOnCompletion { asyncUpdateUiState(viewModelScope) { state -> state.copy(isLoading = false) } } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/navigation/NavBackStackEntryExt.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.navigation 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.remember 5 | import androidx.navigation.NavBackStackEntry 6 | import androidx.navigation.NavController 7 | 8 | @Composable 9 | fun NavBackStackEntry.rememberBackStackEntry( 10 | navController: NavController, 11 | route: String, 12 | ): NavBackStackEntry { 13 | return remember(this) { navController.getBackStackEntry(route) } 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/navigation/NavHostControllerExt.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.navigation 2 | 3 | import androidx.navigation.NavController 4 | 5 | fun NavController.navigateSingleTopTo(route: String) = 6 | this.navigate(route) { launchSingleTop = true } 7 | -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/navigation/NavigationDestination.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.navigation 2 | 3 | sealed class NavigationDestination(open val destination: String) { 4 | 5 | object Login : NavigationDestination("login") 6 | 7 | object Home : NavigationDestination("home") 8 | 9 | object User : NavigationDestination("user") 10 | } 11 | -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/state/CombinedStateDelegate.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.state 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.begoml.uistatedelegate.common.collectIn 6 | import kotlinx.coroutines.CoroutineScope 7 | import kotlinx.coroutines.CoroutineStart 8 | import kotlinx.coroutines.Job 9 | import kotlinx.coroutines.channels.Channel 10 | import kotlinx.coroutines.flow.Flow 11 | import kotlinx.coroutines.flow.combine 12 | import kotlinx.coroutines.flow.launchIn 13 | import kotlinx.coroutines.flow.onEach 14 | import kotlinx.coroutines.launch 15 | import kotlinx.coroutines.sync.Mutex 16 | import kotlin.coroutines.CoroutineContext 17 | import kotlin.coroutines.EmptyCoroutineContext 18 | 19 | interface CombinedStateDelegate : 20 | UiStateDelegate, 21 | InternalStateDelegate { 22 | 23 | /** 24 | * Transforms UI state using the specified transformation. 25 | * 26 | * @param transform - function to transform UI state. 27 | */ 28 | suspend fun CombinedStateDelegate.updateUiState( 29 | transform: (uiState: UiState, state: State) -> UiState 30 | ) 31 | 32 | fun CombinedStateDelegate.collectUpdateUiState( 33 | coroutineScope: CoroutineScope, 34 | transform: (state: State, uiState: UiState) -> UiState, 35 | ): Job 36 | 37 | fun CombinedStateDelegate.combineCollectUpdateUiState( 38 | coroutineScope: CoroutineScope, 39 | flow: Flow, 40 | transform: suspend (state: State, uiState: UiState, value: T) -> UiState, 41 | ): Job 42 | 43 | fun CombinedStateDelegate.combineCollectUpdateUiState( 44 | coroutineScope: CoroutineScope, 45 | flow1: Flow, 46 | flow2: Flow, 47 | transform: suspend (state: State, uiState: UiState, value1: T1, value2: T2) -> UiState, 48 | ): Job 49 | 50 | fun CombinedStateDelegate.combineCollectUpdateUiState( 51 | coroutineScope: CoroutineScope, 52 | flow1: Flow, 53 | flow2: Flow, 54 | flow3: Flow, 55 | transform: suspend (state: State, uiState: UiState, value1: T1, value2: T2, value3: T3) -> UiState, 56 | ): Job 57 | 58 | fun CombinedStateDelegate.combineCollectUpdateUiState( 59 | coroutineScope: CoroutineScope, 60 | flow1: Flow, 61 | flow2: Flow, 62 | flow3: Flow, 63 | flow4: Flow, 64 | transform: suspend (state: State, uiState: UiState, value1: T1, value2: T2, value3: T3, value4: T4) -> UiState, 65 | ): Job 66 | } 67 | 68 | /** 69 | * Implementation of a delegate to manage state. 70 | * This delegate stores and manages two types of state: UI state and internal state. 71 | * 72 | * @param mutexState - mutex for synchronizing state access. 73 | * @param initialUiState - initial UI state. 74 | * @param initialState - initial internal state. 75 | * @param singleLiveEventCapacity - channel capacity for SingleLiveEvent. 76 | */ 77 | class CombinedStateDelegateImpl( 78 | private val mutexState: Mutex = Mutex(), 79 | initialUiState: UiState, 80 | initialState: State, 81 | singleLiveEventCapacity: Int = Channel.BUFFERED, 82 | ) : CombinedStateDelegate, 83 | UiStateDelegate by UiStateDelegateImpl( 84 | mutexState = mutexState, 85 | initialUiState = initialUiState, 86 | singleLiveEventCapacity = singleLiveEventCapacity, 87 | ), 88 | InternalStateDelegate by InternalStateDelegateImpl( 89 | mutexState = mutexState, 90 | initialState = initialState, 91 | ) { 92 | 93 | override suspend fun CombinedStateDelegate.updateUiState( 94 | transform: (uiState: UiState, state: State) -> UiState 95 | ) = updateUiState { uiState -> transform(uiState, internalState) } 96 | 97 | /** 98 | * Subscription to changes in internal state with transformation 99 | * of UI state depending on changes in internal state. 100 | */ 101 | override fun CombinedStateDelegate.collectUpdateUiState( 102 | coroutineScope: CoroutineScope, 103 | transform: (state: State, uiState: UiState) -> UiState, 104 | ): Job { 105 | return internalStateFlow.onEach { state -> 106 | updateUiState { uiState -> transform(state, uiState) } 107 | }.launchIn(coroutineScope) 108 | } 109 | 110 | override fun CombinedStateDelegate.combineCollectUpdateUiState( 111 | coroutineScope: CoroutineScope, 112 | flow: Flow, 113 | transform: suspend (state: State, uiState: UiState, value: T) -> UiState 114 | ): Job { 115 | return internalStateFlow.combine(flow) { state, value -> transform(state, uiState, value) } 116 | .onEach { newState -> updateUiState { _ -> newState } } 117 | .launchIn(coroutineScope) 118 | } 119 | 120 | override fun CombinedStateDelegate.combineCollectUpdateUiState( 121 | coroutineScope: CoroutineScope, 122 | flow1: Flow, 123 | flow2: Flow, 124 | transform: suspend (state: State, uiState: UiState, value1: T1, value2: T2) -> UiState 125 | ): Job { 126 | return combine(internalStateFlow, flow1, flow2) { state, value1, value2 -> 127 | transform( 128 | state, 129 | uiState, 130 | value1, 131 | value2 132 | ) 133 | }.onEach { newState -> updateUiState { _ -> newState } } 134 | .launchIn(coroutineScope) 135 | } 136 | 137 | override fun CombinedStateDelegate.combineCollectUpdateUiState( 138 | coroutineScope: CoroutineScope, 139 | flow1: Flow, 140 | flow2: Flow, 141 | flow3: Flow, 142 | transform: suspend (state: State, uiState: UiState, value1: T1, value2: T2, value3: T3) -> UiState 143 | ): Job { 144 | return combine( 145 | internalStateFlow, 146 | flow1, 147 | flow2, 148 | flow3 149 | ) { state, value1, value2, value3 -> 150 | transform( 151 | state, 152 | uiState, 153 | value1, 154 | value2, 155 | value3 156 | ) 157 | }.onEach { newState -> updateUiState { _ -> newState } } 158 | .launchIn(coroutineScope) 159 | } 160 | 161 | override fun CombinedStateDelegate.combineCollectUpdateUiState( 162 | coroutineScope: CoroutineScope, 163 | flow1: Flow, 164 | flow2: Flow, 165 | flow3: Flow, 166 | flow4: Flow, 167 | transform: suspend (state: State, uiState: UiState, value1: T1, value2: T2, value3: T3, value4: T4) -> UiState 168 | ): Job { 169 | return combine( 170 | internalStateFlow, 171 | flow1, 172 | flow2, 173 | flow3, 174 | flow4 175 | ) { state, value1, value2, value3, value4 -> 176 | transform( 177 | state, 178 | uiState, 179 | value1, 180 | value2, 181 | value3, 182 | value4 183 | ) 184 | }.onEach { newState -> updateUiState { _ -> newState } } 185 | .launchIn(coroutineScope) 186 | } 187 | } 188 | 189 | inline fun CombinedStateDelegate.collectInternalState( 190 | coroutineScope: CoroutineScope, 191 | context: CoroutineContext = EmptyCoroutineContext, 192 | start: CoroutineStart = CoroutineStart.DEFAULT, 193 | crossinline collector: suspend (State) -> Unit 194 | ): Job = coroutineScope.launch( 195 | context = context, start = start 196 | ) { 197 | internalStateFlow.collectIn( 198 | coroutineScope = coroutineScope, 199 | start = start, 200 | collector = collector, 201 | ) 202 | } 203 | 204 | context(ViewModel) 205 | fun CombinedStateDelegate.asyncUpdateInternalState( 206 | transform: (state: State) -> State 207 | ): Job { 208 | return asyncUpdateInternalState( 209 | coroutineScope = viewModelScope, 210 | transform = transform 211 | ) 212 | } 213 | 214 | -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/state/InternalStateDelegate.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.state 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Job 5 | import kotlinx.coroutines.flow.Flow 6 | import kotlinx.coroutines.flow.MutableStateFlow 7 | import kotlinx.coroutines.flow.update 8 | import kotlinx.coroutines.launch 9 | import kotlinx.coroutines.sync.Mutex 10 | import kotlinx.coroutines.sync.withLock 11 | 12 | interface InternalStateDelegate { 13 | 14 | /** 15 | * Get the internal state as data flow. 16 | */ 17 | val InternalStateDelegate.internalStateFlow: Flow 18 | 19 | /** 20 | * Get the current internal state. 21 | */ 22 | val InternalStateDelegate.internalState: State 23 | 24 | /** 25 | * Transforms internal state using the specified transformation. 26 | * 27 | * @param transform - function to transform internal state. 28 | */ 29 | suspend fun InternalStateDelegate.updateInternalState( 30 | transform: (state: State) -> State, 31 | ) 32 | 33 | /** 34 | * Changing the state without blocking the coroutine. 35 | */ 36 | fun InternalStateDelegate.asyncUpdateInternalState( 37 | coroutineScope: CoroutineScope, transform: (state: State) -> State 38 | ): Job 39 | } 40 | 41 | /** 42 | * Implementation of a delegate to manage state. 43 | * This delegate stores and manages internal state. 44 | * 45 | * @param mutexState - mutex for synchronizing state access. 46 | * @param initialState - initial internal state. 47 | */ 48 | class InternalStateDelegateImpl( 49 | private val mutexState: Mutex = Mutex(), 50 | initialState: State, 51 | ) : InternalStateDelegate { 52 | 53 | private val internalMutableState = MutableStateFlow(initialState) 54 | 55 | override val InternalStateDelegate.internalStateFlow: Flow 56 | get() = internalMutableState 57 | 58 | override val InternalStateDelegate.internalState: State 59 | get() = internalMutableState.value 60 | 61 | override suspend fun InternalStateDelegate.updateInternalState( 62 | transform: (state: State) -> State, 63 | ) { 64 | mutexState.withLock { internalMutableState.update(transform) } 65 | } 66 | 67 | override fun InternalStateDelegate.asyncUpdateInternalState( 68 | coroutineScope: CoroutineScope, transform: (state: State) -> State 69 | ): Job { 70 | return coroutineScope.launch { 71 | updateInternalState(transform) 72 | } 73 | } 74 | } -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/state/UiStateDelegate.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.state 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Job 5 | import kotlinx.coroutines.channels.Channel 6 | import kotlinx.coroutines.flow.Flow 7 | import kotlinx.coroutines.flow.MutableStateFlow 8 | import kotlinx.coroutines.flow.StateFlow 9 | import kotlinx.coroutines.flow.asStateFlow 10 | import kotlinx.coroutines.flow.receiveAsFlow 11 | import kotlinx.coroutines.flow.reduce 12 | import kotlinx.coroutines.launch 13 | import kotlinx.coroutines.sync.Mutex 14 | import kotlinx.coroutines.sync.withLock 15 | 16 | /** 17 | * UiState - must be Data class, immutable 18 | */ 19 | interface UiStateDelegate { 20 | 21 | /** 22 | * Declarative description of the UI based on the current state. 23 | */ 24 | val uiStateFlow: StateFlow 25 | 26 | val singleEvents: Flow 27 | 28 | /** 29 | * State is read-only 30 | * The only way to change the state is to emit[reduce] an action, 31 | * an object describing what happened. 32 | */ 33 | val UiStateDelegate.uiState: UiState 34 | 35 | /** 36 | * Transforms UI state using the specified transformation. 37 | * 38 | * @param transform - function to transform UI state. 39 | */ 40 | suspend fun UiStateDelegate.updateUiState( 41 | transform: (uiState: UiState) -> UiState, 42 | ) 43 | 44 | /** 45 | * Changing the state without blocking the coroutine. 46 | */ 47 | fun UiStateDelegate.asyncUpdateUiState( 48 | coroutineScope: CoroutineScope, 49 | transform: (state: UiState) -> UiState, 50 | ): Job 51 | 52 | suspend fun UiStateDelegate.sendEvent(event: Event) 53 | } 54 | 55 | /** 56 | * Implementation of a delegate to manage state. 57 | * This delegate stores and manages UI state. 58 | * 59 | * @param mutexState A mutex for synchronizing state access. 60 | * @param initialUiState Initial UI state. 61 | * @param singleLiveEventCapacity Channel capacity for SingleLiveEvent. 62 | */ 63 | class UiStateDelegateImpl( 64 | initialUiState: UiState, 65 | singleLiveEventCapacity: Int = Channel.BUFFERED, 66 | private val mutexState: Mutex = Mutex() 67 | ) : UiStateDelegate { 68 | 69 | /** 70 | * The source of truth that drives our app. 71 | */ 72 | private val uiMutableStateFlow = MutableStateFlow(initialUiState) 73 | 74 | override val uiStateFlow: StateFlow 75 | get() = uiMutableStateFlow.asStateFlow() 76 | 77 | override val UiStateDelegate.uiState: UiState 78 | get() = uiMutableStateFlow.value 79 | 80 | private val singleEventsChannel = Channel(singleLiveEventCapacity) 81 | 82 | override val singleEvents: Flow 83 | get() = singleEventsChannel.receiveAsFlow() 84 | 85 | override suspend fun UiStateDelegate.updateUiState( 86 | transform: (uiState: UiState) -> UiState, 87 | ) { 88 | mutexState.withLock { 89 | uiMutableStateFlow.emit(transform(uiState)) 90 | } 91 | } 92 | 93 | override suspend fun UiStateDelegate.sendEvent(event: Event) { 94 | singleEventsChannel.send(event) 95 | } 96 | 97 | override fun UiStateDelegate.asyncUpdateUiState( 98 | coroutineScope: CoroutineScope, 99 | transform: (state: UiState) -> UiState, 100 | ): Job { 101 | return coroutineScope.launch { 102 | updateUiState { state -> transform(state) } 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/ui/components/AppTopBar.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.ui.components 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.material.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.unit.dp 9 | import com.begoml.uistatedelegate.features.delegates.ToolbarUiState 10 | 11 | @Composable 12 | fun AppTopBar( 13 | modifier: Modifier = Modifier, 14 | state: ToolbarUiState, 15 | ) { 16 | Column(modifier = modifier) { 17 | Text( 18 | modifier = Modifier.padding(top = 4.dp), 19 | text = state.title, 20 | ) 21 | Text( 22 | modifier = Modifier.padding(top = 2.dp), 23 | text = "${state.levelLabel} ${state.levelProgress}", 24 | ) 25 | } 26 | } -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple200 = Color(0xFFBB86FC) 6 | val Purple500 = Color(0xFF6200EE) 7 | val Purple700 = Color(0xFF3700B3) 8 | val Teal200 = Color(0xFF03DAC5) 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/ui/theme/Shape.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.ui.theme 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.material.Shapes 5 | import androidx.compose.ui.unit.dp 6 | 7 | val Shapes = Shapes( 8 | small = RoundedCornerShape(4.dp), 9 | medium = RoundedCornerShape(4.dp), 10 | large = RoundedCornerShape(0.dp) 11 | ) 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.ui.theme 2 | 3 | import androidx.compose.material.MaterialTheme 4 | import androidx.compose.material.lightColors 5 | import androidx.compose.runtime.Composable 6 | import com.begoml.uistatedelegate.ui.theme.* 7 | 8 | private val Color = lightColors( 9 | primary = Purple500, 10 | primaryVariant = Purple700, 11 | secondary = Teal200 12 | ) 13 | 14 | @Composable 15 | fun AppTheme(content: @Composable () -> Unit) { 16 | MaterialTheme( 17 | colors = Color, 18 | typography = Typography, 19 | shapes = Shapes, 20 | content = content 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.ui.theme 2 | 3 | import androidx.compose.material.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | body1 = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp 15 | ) 16 | /* Other default text styles to override 17 | button = TextStyle( 18 | fontFamily = FontFamily.Default, 19 | fontWeight = FontWeight.W500, 20 | fontSize = 14.sp 21 | ), 22 | caption = TextStyle( 23 | fontFamily = FontFamily.Default, 24 | fontWeight = FontWeight.Normal, 25 | fontSize = 12.sp 26 | ) 27 | */ 28 | ) 29 | -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/uistate/UiStateDelegateExt.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.uistate 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.collectAsState 5 | import androidx.lifecycle.Lifecycle 6 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 7 | import com.begoml.uistatedelegate.state.UiStateDelegate 8 | 9 | @Composable 10 | fun UiStateDelegate.collectUiState() = this.uiStateFlow.collectAsState() 11 | 12 | @Composable 13 | fun UiStateDelegate.collectWithLifecycle( 14 | minActiveState: Lifecycle.State = Lifecycle.State.STARTED, 15 | ) = this.uiStateFlow.collectAsStateWithLifecycle( 16 | minActiveState = minActiveState, 17 | ) 18 | -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/uistate/UiStateDiffRender.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.uistate 2 | 3 | /** 4 | * UiStateDiffRender for efficient ui updates. 5 | */ 6 | class UiStateDiffRender private constructor( 7 | private val renders: List> 8 | ) { 9 | 10 | private var lastUiState: T? = null 11 | 12 | fun render(newState: T) { 13 | lastUiState.let { oldUiState -> 14 | renders.forEach { render -> 15 | val property = render.property 16 | val newProperty = property(newState) 17 | if (oldUiState == null || property(oldUiState) != newProperty) { 18 | render.callback(newProperty) 19 | } 20 | } 21 | } 22 | 23 | lastUiState = newState 24 | } 25 | 26 | private class Render( 27 | val property: (T) -> R, 28 | val callback: (R) -> Unit, 29 | ) 30 | 31 | /** 32 | * it's obligatory to clear render in onDestroyView 33 | */ 34 | fun clear() { 35 | lastUiState = null 36 | } 37 | 38 | class Builder @PublishedApi internal constructor() { 39 | 40 | private val renders = mutableListOf>() 41 | 42 | operator fun ((T) -> R).invoke(callback: (R) -> Unit) { 43 | renders += Render( 44 | property = this, 45 | callback = callback, 46 | ) as Render 47 | } 48 | 49 | fun build(): UiStateDiffRender = UiStateDiffRender(renders) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/src/main/java/com/begoml/uistatedelegate/uistate/UiStateExt.kt: -------------------------------------------------------------------------------- 1 | package com.begoml.uistatedelegate.uistate 2 | 3 | import androidx.appcompat.app.AppCompatActivity 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.LaunchedEffect 6 | import androidx.compose.ui.platform.LocalLifecycleOwner 7 | import androidx.fragment.app.Fragment 8 | import androidx.lifecycle.* 9 | import com.begoml.uistatedelegate.state.UiStateDelegate 10 | import kotlinx.coroutines.Job 11 | import kotlinx.coroutines.flow.FlowCollector 12 | import kotlinx.coroutines.flow.collectLatest 13 | import kotlinx.coroutines.launch 14 | 15 | inline fun Fragment.uiStateDiffRender( 16 | init: UiStateDiffRender.Builder.() -> Unit 17 | ): UiStateDiffRender { 18 | 19 | var render: UiStateDiffRender? = null 20 | 21 | lifecycle.addObserver(object : DefaultLifecycleObserver { 22 | val viewLifecycleOwnerLiveDataObserver = Observer { 23 | val viewLifecycleOwner = it ?: return@Observer 24 | 25 | viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { 26 | override fun onDestroy(owner: LifecycleOwner) { 27 | render?.clear() 28 | } 29 | }) 30 | } 31 | 32 | override fun onCreate(owner: LifecycleOwner) { 33 | viewLifecycleOwnerLiveData.observeForever(viewLifecycleOwnerLiveDataObserver) 34 | } 35 | 36 | override fun onDestroy(owner: LifecycleOwner) { 37 | viewLifecycleOwnerLiveData.removeObserver(viewLifecycleOwnerLiveDataObserver) 38 | render = null 39 | } 40 | }) 41 | 42 | return UiStateDiffRender.Builder() 43 | .apply(init) 44 | .build().apply { 45 | render = this 46 | } 47 | } 48 | 49 | inline fun AppCompatActivity.uiStateDiffRender( 50 | init: UiStateDiffRender.Builder.() -> Unit 51 | ): UiStateDiffRender { 52 | 53 | var render: UiStateDiffRender? = null 54 | 55 | lifecycle.addObserver(object : DefaultLifecycleObserver { 56 | 57 | override fun onDestroy(owner: LifecycleOwner) { 58 | render?.clear() 59 | render = null 60 | } 61 | }) 62 | 63 | return UiStateDiffRender.Builder() 64 | .apply(init) 65 | .build().apply { 66 | render = this 67 | } 68 | } 69 | 70 | /** 71 | * render [State] with [lifecycleState] 72 | * The UI re-renders based on the new state 73 | **/ 74 | fun UiStateDelegate.render( 75 | lifecycleOwner: LifecycleOwner, 76 | lifecycleState: Lifecycle.State = Lifecycle.State.STARTED, 77 | render: UiStateDiffRender 78 | ): Job = lifecycleOwner.lifecycleScope.launch { 79 | uiStateFlow.flowWithLifecycle( 80 | lifecycle = lifecycleOwner.lifecycle, 81 | minActiveState = lifecycleState, 82 | ).collectLatest(render::render) 83 | } 84 | 85 | /** 86 | * render [State] with [AppCompatActivity] 87 | * The UI re-renders based on the new state 88 | **/ 89 | fun UiStateDelegate.render( 90 | lifecycle: Lifecycle, 91 | lifecycleState: Lifecycle.State = Lifecycle.State.STARTED, 92 | render: UiStateDiffRender 93 | ): Job = lifecycle.coroutineScope.launch { 94 | uiStateFlow.flowWithLifecycle( 95 | lifecycle = lifecycle, 96 | minActiveState = lifecycleState, 97 | ).collectLatest(render::render) 98 | } 99 | 100 | /** 101 | * send [Event] with [lifecycleState] 102 | * The UI re-renders based on the new event 103 | **/ 104 | fun UiStateDelegate.collectEvent( 105 | lifecycleOwner: LifecycleOwner, 106 | lifecycleState: Lifecycle.State = Lifecycle.State.RESUMED, 107 | block: (event: Event) -> Unit 108 | ): Job = lifecycleOwner.lifecycleScope.launch { 109 | singleEvents.flowWithLifecycle( 110 | lifecycle = lifecycleOwner.lifecycle, 111 | minActiveState = lifecycleState, 112 | ).collect { event -> 113 | block.invoke(event) 114 | } 115 | } 116 | 117 | /** 118 | * send [Event] with [AppCompatActivity] 119 | * The UI re-renders based on the new event 120 | **/ 121 | fun UiStateDelegate.collectEvent( 122 | lifecycle: Lifecycle, 123 | lifecycleState: Lifecycle.State = Lifecycle.State.RESUMED, 124 | block: (event: Event) -> Unit 125 | ): Job = lifecycle.coroutineScope.launch { 126 | singleEvents.flowWithLifecycle( 127 | lifecycle = lifecycle, 128 | minActiveState = lifecycleState, 129 | ).collect { 130 | block(it) 131 | } 132 | } 133 | 134 | @Composable 135 | fun UiStateDelegate.CollectEventEffect( 136 | lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, 137 | lifecycleState: Lifecycle.State = Lifecycle.State.RESUMED, 138 | vararg keys: Any?, 139 | collector: FlowCollector, 140 | ) = LaunchedEffect(Unit, *keys) { 141 | singleEvents.flowWithLifecycle( 142 | lifecycle = lifecycleOwner.lifecycle, 143 | minActiveState = lifecycleState, 144 | ).collect(collector) 145 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 16 | 19 | 22 | 23 | 24 | 25 | 31 | 32 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 11 | 16 | 21 | 26 | 31 | 36 | 41 | 46 | 51 | 56 | 61 | 66 | 71 | 76 | 81 | 86 | 91 | 96 | 101 | 106 | 111 | 116 | 121 | 126 | 131 | 136 | 141 | 146 | 151 | 156 | 161 | 166 | 171 | 172 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_forgot_password.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_forgot_password.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 19 | 20 | 26 | 27 | 38 | 39 |