├── .gitignore ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── adammcneilly │ │ └── mviexample │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── adammcneilly │ │ │ └── mviexample │ │ │ ├── LoggingMiddleware.kt │ │ │ ├── LoginNetworkingMiddleware.kt │ │ │ ├── LoginRepository.kt │ │ │ ├── MainActivity.kt │ │ │ ├── ProdLoginService.kt │ │ │ ├── redux │ │ │ ├── Action.kt │ │ │ ├── Middleware.kt │ │ │ ├── Reducer.kt │ │ │ ├── State.kt │ │ │ └── Store.kt │ │ │ └── ui │ │ │ ├── login │ │ │ ├── LoginAction.kt │ │ │ ├── LoginFragment.kt │ │ │ ├── LoginReducer.kt │ │ │ ├── LoginViewModel.kt │ │ │ └── LoginViewState.kt │ │ │ └── profile │ │ │ └── ProfileFragment.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ └── ic_launcher_background.xml │ │ ├── layout │ │ ├── fragment_login.xml │ │ ├── fragment_profile.xml │ │ └── main_activity.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-night │ │ └── themes.xml │ │ └── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ └── test │ └── java │ └── com │ └── adammcneilly │ └── mviexample │ ├── ActionCaptureMiddleware.kt │ ├── ExampleUnitTest.kt │ ├── FakeLoginRepository.kt │ ├── LoginNetworkingMiddlewareTest.kt │ ├── redux │ └── StoreTest.kt │ └── ui │ └── login │ └── LoginReducerTest.kt ├── assets └── MVIDiagram.png ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/ 5 | /.idea/caches 6 | /.idea/libraries 7 | /.idea/modules.xml 8 | /.idea/workspace.xml 9 | /.idea/navEditor.xml 10 | /.idea/assetWizardSettings.xml 11 | .DS_Store 12 | /build 13 | /captures 14 | .externalNativeBuild 15 | .cxx 16 | local.properties 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MVI Example 2 | 3 | This application was streamed live on Twitch to demonstrate how to build an application using MVI. 4 | 5 | You can find the VOD here for now: https://www.twitch.tv/videos/1036306656 6 | 7 | And on YouTube: https://www.youtube.com/watch?v=wTJX_lWdh60 8 | 9 | Much of the codebase is documented, but you can expect a blog post coming soon as well. 10 | 11 | ## MVI Diagram 12 | 13 | During the stream we created a diagram to understand the flow of data in an MVI application, which you can find here. This may be helpful to review before looking into the codebase: 14 | 15 | ![](assets/MVIDiagram.png) 16 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'kotlin-android' 4 | } 5 | 6 | android { 7 | compileSdk 30 8 | buildToolsVersion "30.0.3" 9 | 10 | defaultConfig { 11 | applicationId "com.adammcneilly.mviexample" 12 | minSdk 21 13 | targetSdk 30 14 | versionCode 1 15 | versionName "1.0" 16 | 17 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 18 | } 19 | 20 | buildTypes { 21 | release { 22 | minifyEnabled false 23 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 24 | } 25 | } 26 | compileOptions { 27 | sourceCompatibility JavaVersion.VERSION_1_8 28 | targetCompatibility JavaVersion.VERSION_1_8 29 | } 30 | kotlinOptions { 31 | jvmTarget = '1.8' 32 | } 33 | 34 | buildFeatures { 35 | viewBinding = true 36 | } 37 | } 38 | 39 | dependencies { 40 | 41 | implementation 'androidx.core:core-ktx:1.5.0' 42 | implementation 'androidx.appcompat:appcompat:1.3.0' 43 | implementation 'com.google.android.material:material:1.3.0' 44 | implementation 'androidx.constraintlayout:constraintlayout:2.0.4' 45 | implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.1' 46 | implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1' 47 | implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' 48 | implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.3' 49 | testImplementation 'junit:junit:4.13.2' 50 | testImplementation "com.google.truth:truth:1.1.3" 51 | androidTestImplementation 'androidx.test.ext:junit:1.1.2' 52 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' 53 | } -------------------------------------------------------------------------------- /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. 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 -------------------------------------------------------------------------------- /app/src/androidTest/java/com/adammcneilly/mviexample/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.adammcneilly.mviexample 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.adammcneilly.mviexample", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 12 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/src/main/java/com/adammcneilly/mviexample/LoggingMiddleware.kt: -------------------------------------------------------------------------------- 1 | package com.adammcneilly.mviexample 2 | 3 | import android.util.Log 4 | import com.adammcneilly.mviexample.redux.Action 5 | import com.adammcneilly.mviexample.redux.Middleware 6 | import com.adammcneilly.mviexample.redux.State 7 | import com.adammcneilly.mviexample.redux.Store 8 | 9 | /** 10 | * This [Middleware] is responsible for logging every [Action] that is processed to the Logcat, so 11 | * that we can use this for debugging. 12 | */ 13 | class LoggingMiddleware : Middleware { 14 | override suspend fun process(action: A, currentState: S, store: Store) { 15 | Log.v( 16 | "LoggingMiddleware", 17 | "Processing action: $action; Current state: $currentState" 18 | ) 19 | } 20 | } -------------------------------------------------------------------------------- /app/src/main/java/com/adammcneilly/mviexample/LoginNetworkingMiddleware.kt: -------------------------------------------------------------------------------- 1 | package com.adammcneilly.mviexample 2 | 3 | import com.adammcneilly.mviexample.redux.Middleware 4 | import com.adammcneilly.mviexample.redux.Store 5 | import com.adammcneilly.mviexample.ui.login.LoginAction 6 | import com.adammcneilly.mviexample.ui.login.LoginViewState 7 | 8 | class LoginNetworkingMiddleware( 9 | private val loginRepository: LoginRepository, 10 | ) : Middleware { 11 | 12 | override suspend fun process( 13 | action: LoginAction, 14 | currentState: LoginViewState, 15 | store: Store, 16 | ) { 17 | when (action) { 18 | is LoginAction.SignInButtonClicked -> { 19 | if (currentState.email.isEmpty()) { 20 | store.dispatch(LoginAction.InvalidEmailSubmitted) 21 | return 22 | } 23 | 24 | loginUser(store, currentState) 25 | } 26 | } 27 | } 28 | 29 | private suspend fun loginUser( 30 | store: Store, 31 | currentState: LoginViewState 32 | ) { 33 | store.dispatch(LoginAction.LoginStarted) 34 | 35 | val isSuccessful = loginRepository.login( 36 | email = currentState.email, 37 | password = currentState.password, 38 | ) 39 | 40 | if (isSuccessful) { 41 | store.dispatch(LoginAction.LoginCompleted) 42 | } else { 43 | store.dispatch(LoginAction.LoginFailed(null)) 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /app/src/main/java/com/adammcneilly/mviexample/LoginRepository.kt: -------------------------------------------------------------------------------- 1 | package com.adammcneilly.mviexample 2 | 3 | interface LoginRepository { 4 | suspend fun login(email: String, password: String): Boolean 5 | } 6 | -------------------------------------------------------------------------------- /app/src/main/java/com/adammcneilly/mviexample/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.adammcneilly.mviexample 2 | 3 | import androidx.appcompat.app.AppCompatActivity 4 | import android.os.Bundle 5 | import com.adammcneilly.mviexample.ui.login.LoginFragment 6 | import com.adammcneilly.mviexample.ui.profile.ProfileFragment 7 | 8 | class MainActivity : AppCompatActivity() { 9 | 10 | override fun onCreate(savedInstanceState: Bundle?) { 11 | super.onCreate(savedInstanceState) 12 | setContentView(R.layout.main_activity) 13 | if (savedInstanceState == null) { 14 | supportFragmentManager.beginTransaction() 15 | .replace(R.id.container, LoginFragment()) 16 | .commitNow() 17 | } 18 | } 19 | 20 | /** 21 | * To navigate to the profile screen, we'll run a Fragment transaction to replace the login 22 | * screen with the profile screen. 23 | */ 24 | fun navigateToProfile() { 25 | supportFragmentManager.beginTransaction() 26 | .replace(R.id.container, ProfileFragment()) 27 | .commitNow() 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/java/com/adammcneilly/mviexample/ProdLoginService.kt: -------------------------------------------------------------------------------- 1 | package com.adammcneilly.mviexample 2 | 3 | import kotlinx.coroutines.delay 4 | 5 | class ProdLoginService : LoginRepository { 6 | 7 | override suspend fun login(email: String, password: String): Boolean { 8 | delay(2000) 9 | 10 | return true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/adammcneilly/mviexample/redux/Action.kt: -------------------------------------------------------------------------------- 1 | package com.adammcneilly.mviexample.redux 2 | 3 | /** 4 | * While this interface is empty, it is used to clearly define any Action or Intent that was taken 5 | * on a given screen. 6 | */ 7 | interface Action -------------------------------------------------------------------------------- /app/src/main/java/com/adammcneilly/mviexample/redux/Middleware.kt: -------------------------------------------------------------------------------- 1 | package com.adammcneilly.mviexample.redux 2 | 3 | /** 4 | * A [Middleware] is any class that deals with side effects of actions. This can be logging, 5 | * triggering network calls, and other examples. 6 | */ 7 | interface Middleware { 8 | /** 9 | * This will process the given [action] and [currentState] and determine if we need to 10 | * perform any side effects, or trigger a new action. 11 | * 12 | * @param[store] This is a reference to the [Store] that dispatched this action. We should only 13 | * call this with a _new_ action, and not trigger the same action again or risk ending up in a 14 | * loop. 15 | */ 16 | suspend fun process( 17 | action: A, 18 | currentState: S, 19 | store: Store, 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /app/src/main/java/com/adammcneilly/mviexample/redux/Reducer.kt: -------------------------------------------------------------------------------- 1 | package com.adammcneilly.mviexample.redux 2 | 3 | interface Reducer { 4 | 5 | /** 6 | * Given a [currentState] and some [action] that the user took, produce a new [State]. 7 | * 8 | * This will give us clear and predictable state management, that ensures each state is associated 9 | * with some specific user intent or action. 10 | */ 11 | fun reduce(currentState: S, action: A): S 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/adammcneilly/mviexample/redux/State.kt: -------------------------------------------------------------------------------- 1 | package com.adammcneilly.mviexample.redux 2 | 3 | /** 4 | * While this interface is empty, it is used to clearly define any class that is representative 5 | * of a View state for a given screen. 6 | */ 7 | interface State 8 | -------------------------------------------------------------------------------- /app/src/main/java/com/adammcneilly/mviexample/redux/Store.kt: -------------------------------------------------------------------------------- 1 | package com.adammcneilly.mviexample.redux 2 | 3 | import kotlinx.coroutines.flow.MutableStateFlow 4 | import kotlinx.coroutines.flow.StateFlow 5 | 6 | /** 7 | * A [Store] is our state container for a given screen. 8 | * 9 | * @param[initialState] This is the initial state of the screen when it is first created. 10 | * @param[reducer] A system for taking in the current state, and a new action, and outputting the 11 | * updated state. 12 | * @param[middlewares] This is a list of [Middleware] entities for handling any side effects 13 | * for actions dispatched to this store. 14 | */ 15 | class Store( 16 | initialState: S, 17 | private val reducer: Reducer, 18 | private val middlewares: List> = emptyList(), 19 | ) { 20 | 21 | private val _state = MutableStateFlow(initialState) 22 | val state: StateFlow = _state 23 | 24 | private val currentState: S 25 | get() = _state.value 26 | 27 | suspend fun dispatch(action: A) { 28 | middlewares.forEach { middleware -> 29 | middleware.process(action, currentState, this) 30 | } 31 | 32 | val newState = reducer.reduce(currentState, action) 33 | _state.value = newState 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/java/com/adammcneilly/mviexample/ui/login/LoginAction.kt: -------------------------------------------------------------------------------- 1 | package com.adammcneilly.mviexample.ui.login 2 | 3 | import com.adammcneilly.mviexample.redux.Action 4 | 5 | /** 6 | * These are all of the possible actions that can be triggered from the login screen. 7 | */ 8 | sealed class LoginAction : Action { 9 | data class EmailChanged(val newEmail: String) : LoginAction() 10 | data class PasswordChanged(val newPassword: String) : LoginAction() 11 | object SignInButtonClicked : LoginAction() 12 | object LoginStarted : LoginAction() 13 | object LoginCompleted : LoginAction() 14 | data class LoginFailed(val error: Throwable?) : LoginAction() 15 | object InvalidEmailSubmitted : LoginAction() 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/java/com/adammcneilly/mviexample/ui/login/LoginFragment.kt: -------------------------------------------------------------------------------- 1 | package com.adammcneilly.mviexample.ui.login 2 | 3 | import androidx.lifecycle.ViewModelProvider 4 | import android.os.Bundle 5 | import androidx.fragment.app.Fragment 6 | import android.view.LayoutInflater 7 | import android.view.View 8 | import android.view.ViewGroup 9 | import android.widget.Button 10 | import androidx.core.widget.doOnTextChanged 11 | import androidx.lifecycle.lifecycleScope 12 | import com.adammcneilly.mviexample.MainActivity 13 | import com.adammcneilly.mviexample.R 14 | import com.adammcneilly.mviexample.databinding.FragmentLoginBinding 15 | import kotlinx.coroutines.flow.collect 16 | 17 | class LoginFragment : Fragment() { 18 | private lateinit var binding: FragmentLoginBinding 19 | private lateinit var viewModel: LoginViewModel 20 | 21 | override fun onCreate(savedInstanceState: Bundle?) { 22 | super.onCreate(savedInstanceState) 23 | 24 | viewModel = ViewModelProvider(this).get(LoginViewModel::class.java) 25 | 26 | // Whenever the view is resumed, subscribe to our viewmodel's view state StateFlow 27 | lifecycleScope.launchWhenResumed { 28 | viewModel.viewState.collect { viewState -> 29 | processViewState(viewState) 30 | } 31 | } 32 | } 33 | 34 | override fun onCreateView( 35 | inflater: LayoutInflater, 36 | container: ViewGroup?, 37 | savedInstanceState: Bundle?, 38 | ): View { 39 | binding = FragmentLoginBinding.inflate(inflater, container, false) 40 | return binding.root 41 | } 42 | 43 | /** 44 | * Whenever a relevant UI action occurs like a text change or a button click, proxy that 45 | * to the view model to handle. 46 | */ 47 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 48 | super.onViewCreated(view, savedInstanceState) 49 | 50 | binding.emailInput.doOnTextChanged { text, _, _, _ -> 51 | viewModel.emailChanged(text?.toString().orEmpty()) 52 | } 53 | 54 | binding.passwordInput.doOnTextChanged { text, _, _, _ -> 55 | viewModel.passwordChanged(text?.toString().orEmpty()) 56 | } 57 | 58 | binding.signInButton.setOnClickListener { 59 | viewModel.signInButtonClicked() 60 | } 61 | } 62 | 63 | private fun processViewState(viewState: LoginViewState) { 64 | binding.progressBar.visibility = if (viewState.showProgressBar) { 65 | View.VISIBLE 66 | } else { 67 | View.GONE 68 | } 69 | 70 | binding.emailInputLayout.error = viewState.emailError 71 | } 72 | } -------------------------------------------------------------------------------- /app/src/main/java/com/adammcneilly/mviexample/ui/login/LoginReducer.kt: -------------------------------------------------------------------------------- 1 | package com.adammcneilly.mviexample.ui.login 2 | 3 | import com.adammcneilly.mviexample.redux.Reducer 4 | 5 | /** 6 | * This reducer is responsible for handling any [LoginAction], and using that to create 7 | * a new [LoginViewState]. 8 | */ 9 | class LoginReducer : Reducer { 10 | 11 | /** 12 | * Side note: Notice that all of the functions are named in a way that they signify they're 13 | * returning a new state, and not just processing information. This helps keep your when statements 14 | * clear that they're returning stuff, so that context isn't lost. 15 | */ 16 | override fun reduce(currentState: LoginViewState, action: LoginAction): LoginViewState { 17 | return when (action) { 18 | is LoginAction.EmailChanged -> { 19 | stateWithNewEmail(currentState, action) 20 | } 21 | is LoginAction.PasswordChanged -> { 22 | stateWithNewPassword(currentState, action) 23 | } 24 | LoginAction.LoginStarted -> { 25 | stateAfterLoginStarted(currentState) 26 | } 27 | LoginAction.LoginCompleted -> { 28 | stateAfterLoginCompleted(currentState) 29 | } 30 | is LoginAction.LoginFailed -> { 31 | stateAfterLoginFailed(currentState) 32 | } 33 | LoginAction.InvalidEmailSubmitted -> { 34 | stateWithInvalidEmailError(currentState) 35 | } 36 | else -> currentState 37 | } 38 | } 39 | 40 | private fun stateWithInvalidEmailError(currentState: LoginViewState) = 41 | currentState.copy( 42 | emailError = "Please enter an email address.", 43 | ) 44 | 45 | private fun stateAfterLoginStarted(currentState: LoginViewState) = 46 | currentState.copy( 47 | showProgressBar = true, 48 | ) 49 | 50 | private fun stateAfterLoginCompleted(currentState: LoginViewState) = 51 | currentState.copy( 52 | showProgressBar = false, 53 | ) 54 | 55 | private fun stateAfterLoginFailed(currentState: LoginViewState) = 56 | currentState.copy( 57 | showProgressBar = false, 58 | ) 59 | 60 | private fun stateWithNewPassword( 61 | currentState: LoginViewState, 62 | action: LoginAction.PasswordChanged 63 | ) = currentState.copy( 64 | password = action.newPassword, 65 | ) 66 | 67 | private fun stateWithNewEmail( 68 | currentState: LoginViewState, 69 | action: LoginAction.EmailChanged 70 | ) = currentState.copy( 71 | email = action.newEmail, 72 | ) 73 | } -------------------------------------------------------------------------------- /app/src/main/java/com/adammcneilly/mviexample/ui/login/LoginViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.adammcneilly.mviexample.ui.login 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.adammcneilly.mviexample.LoggingMiddleware 6 | import com.adammcneilly.mviexample.LoginNetworkingMiddleware 7 | import com.adammcneilly.mviexample.ProdLoginService 8 | import com.adammcneilly.mviexample.redux.Store 9 | import kotlinx.coroutines.flow.StateFlow 10 | import kotlinx.coroutines.launch 11 | 12 | /** 13 | * The [LoginViewModel] is responsible for controlling the UI logic of the login screen. It will 14 | * listen for text changes and button clicks, and update the UI state accordingly and expose that so 15 | * the View can update. 16 | * 17 | * Whenever a view action occurs, such as [emailChanged] or [signInButtonClicked], proxy the 18 | * corresponding [LoginAction] to our [store]. 19 | */ 20 | class LoginViewModel : ViewModel() { 21 | private val store = Store( 22 | initialState = LoginViewState(), 23 | reducer = LoginReducer(), 24 | middlewares = listOf( 25 | LoggingMiddleware(), 26 | LoginNetworkingMiddleware( 27 | loginRepository = ProdLoginService(), 28 | ), 29 | ) 30 | ) 31 | 32 | val viewState: StateFlow = store.state 33 | 34 | fun emailChanged(newEmail: String) { 35 | val action = LoginAction.EmailChanged(newEmail) 36 | 37 | viewModelScope.launch { 38 | store.dispatch(action) 39 | } 40 | } 41 | 42 | fun passwordChanged(newPassword: String) { 43 | val action = LoginAction.PasswordChanged(newPassword) 44 | 45 | viewModelScope.launch { 46 | store.dispatch(action) 47 | } 48 | } 49 | 50 | fun signInButtonClicked() { 51 | val action = LoginAction.SignInButtonClicked 52 | 53 | viewModelScope.launch { 54 | store.dispatch(action) 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /app/src/main/java/com/adammcneilly/mviexample/ui/login/LoginViewState.kt: -------------------------------------------------------------------------------- 1 | package com.adammcneilly.mviexample.ui.login 2 | 3 | import com.adammcneilly.mviexample.redux.State 4 | 5 | /** 6 | * An implementation of [State] that describes the configuration of the login screen at a given time. 7 | */ 8 | data class LoginViewState( 9 | val email: String = "", 10 | val password: String = "", 11 | val showProgressBar: Boolean = false, 12 | val emailError: String? = null, 13 | val passwordError: String? = null, 14 | ) : State -------------------------------------------------------------------------------- /app/src/main/java/com/adammcneilly/mviexample/ui/profile/ProfileFragment.kt: -------------------------------------------------------------------------------- 1 | package com.adammcneilly.mviexample.ui.profile 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.fragment.app.Fragment 8 | import com.adammcneilly.mviexample.R 9 | 10 | /** 11 | * At the moment, this fragment is just a stub to show that we can test navigation, but doesn't 12 | * actually hold any logic for this sample application. 13 | */ 14 | class ProfileFragment : Fragment() { 15 | 16 | override fun onCreateView( 17 | inflater: LayoutInflater, container: ViewGroup?, 18 | savedInstanceState: Bundle? 19 | ): View { 20 | return inflater.inflate(R.layout.fragment_profile, container, false) 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_login.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 18 | 19 | 25 | 26 | 27 | 36 | 37 | 43 | 44 | 45 |