├── .gitignore
├── .idea
├── .gitignore
├── .name
├── compiler.xml
├── gradle.xml
├── misc.xml
└── vcs.xml
├── README.md
├── app
├── .gitignore
├── build.gradle
├── proguard-rules.pro
└── src
│ ├── main
│ ├── AndroidManifest.xml
│ ├── java
│ │ └── com
│ │ │ └── example
│ │ │ └── statereducerflow
│ │ │ ├── StateReducerFlow.kt
│ │ │ ├── data
│ │ │ ├── Movie.kt
│ │ │ └── MoviesJson.kt
│ │ │ ├── logic
│ │ │ └── FetchMovies.kt
│ │ │ └── ui
│ │ │ ├── MoviesListActivity.kt
│ │ │ ├── MoviesListEvent.kt
│ │ │ ├── MoviesListState.kt
│ │ │ └── MoviesListViewModel.kt
│ └── res
│ │ ├── drawable-v24
│ │ └── ic_launcher_foreground.xml
│ │ ├── drawable
│ │ ├── ic_launcher_background.xml
│ │ ├── ic_shuffle.xml
│ │ ├── ic_star.xml
│ │ └── ic_star_outline.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
│ └── test
│ └── java
│ └── com
│ └── example
│ └── statereducerflow
│ ├── MoviesListViewModelTest.kt
│ └── TestCoroutineRule.kt
├── build.gradle
├── demo.gif
├── 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/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 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 |
--------------------------------------------------------------------------------
/.idea/.name:
--------------------------------------------------------------------------------
1 | StateReducerFlow
--------------------------------------------------------------------------------
/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
19 |
20 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # All you need for MVI is Kotlin. How to reduce without a reducer?
2 |
3 | ## :scroll: Description
4 |
5 | This repository contains [the article](https://maciej-sady.medium.com/all-you-need-for-mvi-is-kotlin-how-to-reduce-without-reducer-5e986856610f) describing my attempt to implement a simple state reducer based on [Kotlin Flow](https://kotlinlang.org/docs/flow.html) and an example app that uses it.
6 |
7 | ## :bulb: Motivation and Context
8 |
9 | Like any Android developer following the latest trends, I like MVI architecture and the unidirectional
10 | data flow concept. It solves many issues out of the box making our code even more bulletproof.
11 |
12 | 
13 |
14 | In this article, I won't go into detail about what MVI is, but you can find many great write-ups about it, e.g.
15 | - [Modern Android Architecture with MVI design pattern](https://amsterdamstandard.com/en/post/modern-android-architecture-with-mvi-design-pattern),
16 | - [MVI beyond state reducers](https://medium.com/bumble-tech/a-modern-kotlin-based-mvi-architecture-9924e08efab1).
17 |
18 | Playing with libraries like [MVICore](https://github.com/badoo/MVICore), [Mobius](https://github.com/spotify/mobius), or [Orbit](https://github.com/orbit-mvi/orbit-mvi) inspired me to experiment and try to implement a flow that can perform state reduction.
19 |
20 | That's how [StateReducerFlow](https://github.com/linean/StateReducerFlow/blob/main/app/src/main/java/com/example/statereducerflow/StateReducerFlow.kt) was born. Let me explain how I've built it, how it works, and how you can use it.
21 |
22 | ## :man_student: Thinking process
23 |
24 | _Please keep in mind that the following examples are simplified._
25 |
26 | Let's start with a simple counter. It has one state that can be changed with two events: decrement and increment.
27 |
28 | ```
29 | sealed class Event {
30 | object Increment : Event()
31 | object Decrement : Event()
32 | }
33 |
34 | data class State(
35 | val counter: Int = 0
36 | )
37 |
38 | class ViewModel {
39 | val state = MutableStateFlow(State())
40 |
41 | fun handleEvent(event: Event) {
42 | when (event) {
43 | is Increment -> state.update { it.copy(counter = it.counter + 1) }
44 | is Decrement -> state.update { it.copy(counter = it.counter - 1) }
45 | }
46 | }
47 | }
48 | ```
49 |
50 | Using the above approach, we can structure our logic in the following way:
51 | ```
52 | Event -> ViewModel -> State
53 | ```
54 |
55 | One issue, though, is that `handleEvent` can be called from any thread.
56 | Having unstructured state updates can lead to tricky bugs and race conditions.
57 | Luckily, [state.update()](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/update.html) is already thread-safe, but still, any other logic can be affected.
58 |
59 | To solve that we can introduce a channel that will allow us to process events sequentially, no matter from which thread they come.
60 |
61 | ```
62 | class ViewModel {
63 |
64 | private val events = Channel()
65 |
66 | val state = MutableStateFlow(State())
67 |
68 | init {
69 | events.receiveAsFlow()
70 | .onEach(::updateState)
71 | .launchIn(viewModelScope)
72 | }
73 |
74 | fun handleEvent(event: Event) {
75 | events.trySend(event)
76 | }
77 |
78 | private fun updateState(event: Event) {
79 | when (event) {
80 | is Increment -> state.update { it.copy(counter = it.counter + 1) }
81 | is Decrement -> state.update { it.copy(counter = it.counter - 1) }
82 | }
83 | }
84 | }
85 | ```
86 |
87 | Much better. Now we process all events sequentially but state updates are still possible outside of the `updateState` method.
88 | Ideally, state updates should be only allowed during event processing.
89 |
90 | To achieve that we can implement a simple reducer using [runningFold](https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/running-fold.html).
91 |
92 | ```
93 | class ViewModel {
94 |
95 | private val events = Channel()
96 |
97 | val state = events.receiveAsFlow()
98 | .runningFold(State(), ::reduceState)
99 | .stateIn(viewModelScope, Eagerly, State())
100 |
101 | fun handleEvent(event: Event) {
102 | events.trySend(event)
103 | }
104 |
105 | private fun reduceState(currentState: State, event: Event): State {
106 | return when (event) {
107 | is Increment -> currentState.copy(counter = currentState.counter + 1)
108 | is Decrement -> currentState.copy(counter = currentState.counter - 1)
109 | }
110 | }
111 | }
112 | ```
113 |
114 | Now only the `reduceState` method can perform state transformations.
115 |
116 | 
117 |
118 | When you look at this example ViewModel you may notice that only the `reduceState` method contains important logic.
119 | Everything else is just boilerplate that needs to be repeated for every new ViewModel.
120 |
121 | As we all like to stay [DRY](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself), I needed to extract the generic logic from the ViewModel.
122 |
123 | That's how StateReducerFlow was born.
124 |
125 | ## :rocket: StateReducerFlow
126 |
127 | I wanted [StateReducerFlow](https://github.com/linean/StateReducerFlow/blob/main/app/src/main/java/com/example/statereducerflow/StateReducerFlow.kt) to be a [StateFlow](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-state-flow) that can handle generic events. I started with this definition:
128 | ```
129 | interface StateReducerFlow : StateFlow {
130 | fun handleEvent(event: EVENT)
131 | }
132 | ```
133 |
134 | Moving forward I extracted my ViewModel logic to the new flow implementation:
135 |
136 | ```
137 | private class StateReducerFlowImpl(
138 | initialState: STATE,
139 | reduceState: (STATE, EVENT) -> STATE,
140 | scope: CoroutineScope
141 | ) : StateReducerFlow {
142 |
143 | private val events = Channel()
144 |
145 | private val stateFlow = events
146 | .receiveAsFlow()
147 | .runningFold(initialState, reduceState)
148 | .stateIn(scope, Eagerly, initialState)
149 |
150 | override val replayCache get() = stateFlow.replayCache
151 |
152 | override val value get() = stateFlow.value
153 |
154 | override suspend fun collect(collector: FlowCollector): Nothing {
155 | stateFlow.collect(collector)
156 | }
157 |
158 | override fun handleEvent(event: EVENT) {
159 | events.trySend(event)
160 | }
161 | }
162 | ```
163 |
164 | As you can see, the only new things are a few overrides from [StateFlow](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-state-flow).
165 | To construct the flow you provide the initial state, the function that can reduce it, and the coroutine scope in which the state can be shared.
166 |
167 | The last missing part is a factory function that can create our new flow. I've decided to go with ViewModel extension to access [viewModelScope](https://developer.android.com/topic/libraries/architecture/coroutines#viewmodelscope).
168 |
169 | ```
170 | fun ViewModel.StateReducerFlow(
171 | initialState: STATE,
172 | reduceState: (STATE, EVENT) -> STATE,
173 | ): StateReducerFlow = StateReducerFlowImpl(initialState, reduceState, viewModelScope)
174 | ```
175 |
176 | Now we can migrate our ViewModel to the new [StateReducerFlow](https://github.com/linean/StateReducerFlow/blob/main/app/src/main/java/com/example/statereducerflow/StateReducerFlow.kt).
177 |
178 | ```
179 | class ViewModel {
180 |
181 | val state = StateReducerFlow(
182 | initialState = State(),
183 | reduceState = ::reduceState
184 | )
185 |
186 | private fun reduceState(currentState: State, event: Event): State {
187 | return when (event) {
188 | is Increment -> currentState.copy(counter = currentState.counter + 1)
189 | is Decrement -> currentState.copy(counter = currentState.counter - 1)
190 | }
191 | }
192 | }
193 | ```
194 |
195 | Voilà! The boilerplate is gone.
196 |
197 | 
198 |
199 | Now anyone who has access to [StateReducerFlow](https://github.com/linean/StateReducerFlow/blob/main/app/src/main/java/com/example/statereducerflow/StateReducerFlow.kt) can send events to it, e.g.
200 |
201 | ```
202 | class ExampleActivity : Activity() {
203 |
204 | private val viewModel = ViewModel()
205 |
206 | override fun onCreate(savedInstanceState: Bundle?) {
207 | super.onCreate(savedInstanceState)
208 | viewModel.state.handleEvent(ExampleEvent)
209 | }
210 | }
211 | ```
212 |
213 | That's it! Are you interested in how it works in a real app or how it can be tested?
214 | See my example project: https://github.com/linean/StateReducerFlow/tree/main/app/src
215 |
216 |
217 |
218 |
219 |
220 | Stay inspired!
221 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/app/build.gradle:
--------------------------------------------------------------------------------
1 | plugins {
2 | id 'com.android.application'
3 | id 'org.jetbrains.kotlin.android'
4 | id 'org.jetbrains.kotlin.plugin.serialization'
5 | }
6 |
7 | android {
8 | compileSdk 32
9 |
10 | defaultConfig {
11 | applicationId "com.example.statereducerflow"
12 | minSdk 26
13 | targetSdk 32
14 | versionCode 1
15 | versionName "1.0"
16 |
17 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
18 | vectorDrawables {
19 | useSupportLibrary true
20 | }
21 | }
22 |
23 | buildTypes {
24 | release {
25 | minifyEnabled false
26 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
27 | }
28 | }
29 | compileOptions {
30 | sourceCompatibility JavaVersion.VERSION_1_8
31 | targetCompatibility JavaVersion.VERSION_1_8
32 | }
33 | kotlinOptions {
34 | jvmTarget = '1.8'
35 | }
36 | buildFeatures {
37 | compose true
38 | }
39 | composeOptions {
40 | kotlinCompilerExtensionVersion compose_version
41 | }
42 | packagingOptions {
43 | resources {
44 | excludes += '/META-INF/{AL2.0,LGPL2.1}'
45 | }
46 | }
47 | }
48 |
49 | dependencies {
50 |
51 | implementation 'androidx.core:core-ktx:1.7.0'
52 | implementation "androidx.compose.ui:ui:$compose_version"
53 | implementation "androidx.compose.material:material:$compose_version"
54 | implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
55 | implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
56 | implementation 'androidx.activity:activity-compose:1.4.0'
57 | implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0'
58 | implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0'
59 | implementation 'com.google.accompanist:accompanist-swiperefresh:0.23.1'
60 | implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2'
61 | implementation "io.coil-kt:coil-compose:2.0.0-rc02"
62 |
63 | testImplementation 'junit:junit:4.13.2'
64 | testImplementation "io.mockk:mockk:1.12.3"
65 | testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0"
66 | }
--------------------------------------------------------------------------------
/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/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
14 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/statereducerflow/StateReducerFlow.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("FunctionName")
2 |
3 | package com.example.statereducerflow
4 |
5 | import androidx.lifecycle.ViewModel
6 | import androidx.lifecycle.viewModelScope
7 | import kotlinx.coroutines.CoroutineScope
8 | import kotlinx.coroutines.channels.Channel
9 | import kotlinx.coroutines.channels.Channel.Factory.BUFFERED
10 | import kotlinx.coroutines.flow.*
11 | import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly
12 |
13 | interface StateReducerFlow : StateFlow {
14 | fun handleEvent(event: EVENT)
15 | }
16 |
17 | fun ViewModel.StateReducerFlow(
18 | initialState: STATE,
19 | reduceState: (STATE, EVENT) -> STATE,
20 | ): StateReducerFlow = StateReducerFlow(initialState, reduceState, viewModelScope)
21 |
22 | fun StateReducerFlow(
23 | initialState: STATE,
24 | reduceState: (STATE, EVENT) -> STATE,
25 | scope: CoroutineScope
26 | ): StateReducerFlow = StateReducerFlowImpl(initialState, reduceState, scope)
27 |
28 | private class StateReducerFlowImpl(
29 | initialState: STATE,
30 | reduceState: (STATE, EVENT) -> STATE,
31 | scope: CoroutineScope
32 | ) : StateReducerFlow {
33 |
34 | private val events = Channel(BUFFERED)
35 |
36 | private val stateFlow = events
37 | .receiveAsFlow()
38 | .runningFold(initialState, reduceState)
39 | .stateIn(scope, Eagerly, initialState)
40 |
41 | override val replayCache get() = stateFlow.replayCache
42 |
43 | override val value get() = stateFlow.value
44 |
45 | override suspend fun collect(collector: FlowCollector): Nothing {
46 | stateFlow.collect(collector)
47 | }
48 |
49 | override fun handleEvent(event: EVENT) {
50 | val delivered = events.trySend(event).isSuccess
51 | if (!delivered) {
52 | error("Missed event $event! You are doing something wrong during state transformation.")
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/statereducerflow/data/Movie.kt:
--------------------------------------------------------------------------------
1 | package com.example.statereducerflow.data
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | @Serializable
6 | data class Movie(
7 | val id: Int,
8 | val name: String,
9 | val imageUrl: String,
10 | val isSelected: Boolean = false
11 | )
12 |
13 | fun List.copySelectionFrom(movies: List): List {
14 | val selectedIds = movies.filter { it.isSelected }.map { it.id }.toSet()
15 | return map { movie -> movie.copy(isSelected = selectedIds.contains(movie.id)) }
16 | }
17 |
18 | fun List.toggleSelection(movieId: Int) = map { movie ->
19 | if (movie.id == movieId) movie.copy(isSelected = !movie.isSelected)
20 | else movie
21 | }
22 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/statereducerflow/data/MoviesJson.kt:
--------------------------------------------------------------------------------
1 | package com.example.statereducerflow.data
2 |
3 | val moviesJson
4 | get() = """[
5 | {
6 | "id": 1,
7 | "name": "The Shawshank Redemption",
8 | "imageUrl": "https://m.media-amazon.com/images/M/MV5BMDFkYTc0MGEtZmNhMC00ZDIzLWFmNTEtODM1ZmRlYWMwMWFmXkEyXkFqcGdeQXVyMTMxODk2OTU@._V1_UY67_CR0,0,45,67_AL_.jpg"
9 | },
10 | {
11 | "id": 2,
12 | "name": "The Godfather",
13 | "imageUrl": "https://m.media-amazon.com/images/M/MV5BM2MyNjYxNmUtYTAwNi00MTYxLWJmNWYtYzZlODY3ZTk3OTFlXkEyXkFqcGdeQXVyNzkwMjQ5NzM@._V1_UY67_CR1,0,45,67_AL_.jpg"
14 | },
15 | {
16 | "id": 3,
17 | "name": "The Dark Knight",
18 | "imageUrl": "https://m.media-amazon.com/images/M/MV5BMTMxNTMwODM0NF5BMl5BanBnXkFtZTcwODAyMTk2Mw@@._V1_UY67_CR0,0,45,67_AL_.jpg"
19 | },
20 | {
21 | "id": 4,
22 | "name": "The Godfather: Part II",
23 | "imageUrl": "https://m.media-amazon.com/images/M/MV5BMWMwMGQzZTItY2JlNC00OWZiLWIyMDctNDk2ZDQ2YjRjMWQ0XkEyXkFqcGdeQXVyNzkwMjQ5NzM@._V1_UY67_CR1,0,45,67_AL_.jpg"
24 | },
25 | {
26 | "id": 5,
27 | "name": "12 Angry Men",
28 | "imageUrl": "https://m.media-amazon.com/images/M/MV5BMWU4N2FjNzYtNTVkNC00NzQ0LTg0MjAtYTJlMjFhNGUxZDFmXkEyXkFqcGdeQXVyNjc1NTYyMjg@._V1_UX45_CR0,0,45,67_AL_.jpg"
29 | },
30 | {
31 | "id": 6,
32 | "name": "Schindler's List",
33 | "imageUrl": "https://m.media-amazon.com/images/M/MV5BNDE4OTMxMTctNmRhYy00NWE2LTg3YzItYTk3M2UwOTU5Njg4XkEyXkFqcGdeQXVyNjU0OTQ0OTY@._V1_UX45_CR0,0,45,67_AL_.jpg"
34 | },
35 | {
36 | "id": 7,
37 | "name": "The Lord of the Rings: The Return of the King",
38 | "imageUrl": "https://m.media-amazon.com/images/M/MV5BNzA5ZDNlZWMtM2NhNS00NDJjLTk4NDItYTRmY2EwMWZlMTY3XkEyXkFqcGdeQXVyNzkwMjQ5NzM@._V1_UY67_CR0,0,45,67_AL_.jpg"
39 | },
40 | {
41 | "id": 8,
42 | "name": "Pulp Fiction",
43 | "imageUrl": "https://m.media-amazon.com/images/M/MV5BNGNhMDIzZTUtNTBlZi00MTRlLWFjM2ItYzViMjE3YzI5MjljXkEyXkFqcGdeQXVyNzkwMjQ5NzM@._V1_UY67_CR0,0,45,67_AL_.jpg"
44 | },
45 | {
46 | "id": 9,
47 | "name": "The Lord of the Rings: The Fellowship of the Ring",
48 | "imageUrl": "https://m.media-amazon.com/images/M/MV5BN2EyZjM3NzUtNWUzMi00MTgxLWI0NTctMzY4M2VlOTdjZWRiXkEyXkFqcGdeQXVyNDUzOTQ5MjY@._V1_UY67_CR0,0,45,67_AL_.jpg"
49 | },
50 | {
51 | "id": 10,
52 | "name": "The Good, the Bad and the Ugly",
53 | "imageUrl": "https://m.media-amazon.com/images/M/MV5BNjJlYmNkZGItM2NhYy00MjlmLTk5NmQtNjg1NmM2ODU4OTMwXkEyXkFqcGdeQXVyMjUzOTY1NTc@._V1_UX45_CR0,0,45,67_AL_.jpg"
54 | }
55 | ]
56 | """
57 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/statereducerflow/logic/FetchMovies.kt:
--------------------------------------------------------------------------------
1 | package com.example.statereducerflow.logic
2 |
3 | import com.example.statereducerflow.data.Movie
4 | import com.example.statereducerflow.data.moviesJson
5 | import kotlinx.coroutines.Dispatchers
6 | import kotlinx.coroutines.delay
7 | import kotlinx.coroutines.withContext
8 | import kotlinx.serialization.decodeFromString
9 | import kotlinx.serialization.json.Json
10 | import kotlin.random.Random
11 |
12 | class FetchMovies {
13 |
14 | suspend operator fun invoke(): List = withContext(Dispatchers.IO) {
15 | delay(Random.nextLong(300, 2000))
16 | Json.decodeFromString(moviesJson)
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/statereducerflow/ui/MoviesListActivity.kt:
--------------------------------------------------------------------------------
1 | package com.example.statereducerflow.ui
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import androidx.activity.viewModels
7 | import androidx.compose.foundation.ExperimentalFoundationApi
8 | import androidx.compose.foundation.background
9 | import androidx.compose.foundation.clickable
10 | import androidx.compose.foundation.layout.*
11 | import androidx.compose.foundation.lazy.LazyColumn
12 | import androidx.compose.foundation.lazy.items
13 | import androidx.compose.foundation.shape.CircleShape
14 | import androidx.compose.material.Icon
15 | import androidx.compose.material.MaterialTheme
16 | import androidx.compose.material.Text
17 | import androidx.compose.runtime.Composable
18 | import androidx.compose.runtime.collectAsState
19 | import androidx.compose.runtime.getValue
20 | import androidx.compose.ui.Alignment
21 | import androidx.compose.ui.Alignment.Companion.BottomEnd
22 | import androidx.compose.ui.Modifier
23 | import androidx.compose.ui.draw.clip
24 | import androidx.compose.ui.draw.shadow
25 | import androidx.compose.ui.graphics.Color
26 | import androidx.compose.ui.layout.ContentScale
27 | import androidx.compose.ui.res.painterResource
28 | import androidx.compose.ui.unit.dp
29 | import coil.compose.AsyncImage
30 | import com.example.statereducerflow.R
31 | import com.example.statereducerflow.data.Movie
32 | import com.example.statereducerflow.ui.MoviesListEvent.*
33 | import com.google.accompanist.swiperefresh.SwipeRefresh
34 | import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
35 |
36 | class MoviesListActivity : ComponentActivity() {
37 |
38 | private val viewModel: MoviesListViewModel by viewModels()
39 |
40 | override fun onCreate(savedInstanceState: Bundle?) {
41 | super.onCreate(savedInstanceState)
42 | setContent {
43 | val state by viewModel.state.collectAsState()
44 | MoviesListScreen(state, viewModel.state::handleEvent)
45 | }
46 | }
47 |
48 | override fun onStart() {
49 | super.onStart()
50 | viewModel.state.handleEvent(ScreenStarted)
51 | }
52 | }
53 |
54 | @Composable
55 | private fun MoviesListScreen(
56 | state: MoviesListState,
57 | handleEvent: (MoviesListEvent) -> Unit
58 | ) = MaterialTheme {
59 | Box(modifier = Modifier.fillMaxSize()) {
60 | MoviesList(
61 | title = state.title,
62 | movies = state.movies,
63 | isLoading = state.isLoading,
64 | handleEvent = handleEvent
65 | )
66 |
67 | ShuffleButton(
68 | handleEvent = handleEvent,
69 | modifier = Modifier.align(BottomEnd)
70 | )
71 | }
72 | }
73 |
74 | @OptIn(ExperimentalFoundationApi::class)
75 | @Composable
76 | private fun MoviesList(
77 | title: String,
78 | movies: List,
79 | isLoading: Boolean,
80 | handleEvent: (MoviesListEvent) -> Unit
81 | ) {
82 | SwipeRefresh(
83 | state = rememberSwipeRefreshState(isRefreshing = isLoading),
84 | onRefresh = { handleEvent(RefreshClicked) }
85 | ) {
86 | LazyColumn(
87 | modifier = Modifier.fillMaxSize(),
88 | contentPadding = PaddingValues(bottom = 78.dp)
89 | ) {
90 | item {
91 | MoviesHeader(title)
92 | }
93 |
94 | items(
95 | items = movies,
96 | key = Movie::id
97 | ) { movie ->
98 | Movie(
99 | movie = movie,
100 | handleEvent = handleEvent,
101 | modifier = Modifier.animateItemPlacement()
102 | )
103 | }
104 | }
105 | }
106 | }
107 |
108 | @Composable
109 | private fun MoviesHeader(title: String) {
110 | Text(
111 | text = title,
112 | style = MaterialTheme.typography.h3,
113 | modifier = Modifier.padding(16.dp)
114 | )
115 | }
116 |
117 | @Composable
118 | private fun Movie(
119 | movie: Movie,
120 | handleEvent: (MoviesListEvent) -> Unit,
121 | modifier: Modifier = Modifier
122 | ) {
123 | Row(
124 | verticalAlignment = Alignment.CenterVertically,
125 | modifier = modifier
126 | .fillMaxWidth()
127 | .clickable { handleEvent(MovieClicked(movie)) }
128 | .padding(horizontal = 16.dp, vertical = 4.dp)
129 | ) {
130 | AsyncImage(
131 | model = movie.imageUrl,
132 | contentDescription = null,
133 | contentScale = ContentScale.Crop,
134 | modifier = Modifier
135 | .size(40.dp, 64.dp)
136 | .background(MaterialTheme.colors.onSurface.copy(alpha = 0.2F))
137 | )
138 |
139 | Text(
140 | text = movie.name,
141 | style = MaterialTheme.typography.subtitle1,
142 | modifier = Modifier
143 | .weight(1f)
144 | .padding(start = 8.dp)
145 | )
146 |
147 | val imageResource = when (movie.isSelected) {
148 | true -> R.drawable.ic_star
149 | false -> R.drawable.ic_star_outline
150 | }
151 |
152 | Icon(
153 | painter = painterResource(imageResource),
154 | contentDescription = null,
155 | tint = Color(0xFFFFC107),
156 | modifier = Modifier.size(32.dp)
157 | )
158 | }
159 | }
160 |
161 | @Composable
162 | private fun ShuffleButton(
163 | handleEvent: (MoviesListEvent) -> Unit,
164 | modifier: Modifier = Modifier,
165 | ) {
166 | Icon(
167 | painter = painterResource(id = R.drawable.ic_shuffle),
168 | contentDescription = null,
169 | tint = Color.White,
170 | modifier = modifier
171 | .padding(16.dp)
172 | .size(56.dp)
173 | .shadow(4.dp, CircleShape)
174 | .background(MaterialTheme.colors.secondary)
175 | .clip(CircleShape)
176 | .clickable { handleEvent(ShuffleClicked) }
177 | .padding(12.dp)
178 | )
179 | }
180 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/statereducerflow/ui/MoviesListEvent.kt:
--------------------------------------------------------------------------------
1 | package com.example.statereducerflow.ui
2 |
3 | import com.example.statereducerflow.data.Movie
4 |
5 | sealed class MoviesListEvent {
6 | object ScreenStarted : MoviesListEvent()
7 | object RefreshClicked : MoviesListEvent()
8 | object ShuffleClicked : MoviesListEvent()
9 | data class MoviesLoaded(val movies: List) : MoviesListEvent()
10 | data class MovieClicked(val movie: Movie) : MoviesListEvent()
11 | }
12 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/statereducerflow/ui/MoviesListState.kt:
--------------------------------------------------------------------------------
1 | package com.example.statereducerflow.ui
2 |
3 | import com.example.statereducerflow.data.Movie
4 |
5 | data class MoviesListState(
6 | val isLoading: Boolean,
7 | val title: String,
8 | val movies: List
9 | ) {
10 |
11 | companion object {
12 | val initial = MoviesListState(
13 | isLoading = false,
14 | title = "IMDb\nTop 10 Movies",
15 | movies = emptyList()
16 | )
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/statereducerflow/ui/MoviesListViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.example.statereducerflow.ui
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import com.example.statereducerflow.StateReducerFlow
6 | import com.example.statereducerflow.data.copySelectionFrom
7 | import com.example.statereducerflow.data.toggleSelection
8 | import com.example.statereducerflow.logic.FetchMovies
9 | import com.example.statereducerflow.ui.MoviesListEvent.*
10 | import kotlinx.coroutines.launch
11 |
12 | class MoviesListViewModel(
13 | val fetchMovies: FetchMovies = FetchMovies()
14 | ) : ViewModel() {
15 |
16 | val state = StateReducerFlow(
17 | initialState = MoviesListState.initial,
18 | reduceState = ::reduceState,
19 | )
20 |
21 | private fun reduceState(
22 | currentState: MoviesListState,
23 | event: MoviesListEvent
24 | ): MoviesListState = when (event) {
25 | is ScreenStarted,
26 | is RefreshClicked -> {
27 | refreshMovies()
28 | currentState.copy(isLoading = true)
29 | }
30 |
31 | is MovieClicked -> {
32 | val clickedId = event.movie.id
33 | val updatedMovies = currentState.movies.toggleSelection(clickedId)
34 | currentState.copy(movies = updatedMovies)
35 | }
36 |
37 | is ShuffleClicked -> {
38 | val shuffledMovies = currentState.movies.shuffled()
39 | currentState.copy(movies = shuffledMovies)
40 | }
41 |
42 | is MoviesLoaded -> {
43 | val newMovies = event.movies
44 | val currentMovies = currentState.movies
45 | val updatedMovies = newMovies.copySelectionFrom(currentMovies)
46 | currentState.copy(isLoading = false, movies = updatedMovies)
47 | }
48 | }
49 |
50 | private fun refreshMovies() = viewModelScope.launch {
51 | val movies = fetchMovies()
52 | state.handleEvent(MoviesLoaded(movies))
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/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/drawable/ic_shuffle.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_star.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_star_outline.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/linean/StateReducerFlow/742e624e9bef955124094956de394c9eb458d54e/app/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/linean/StateReducerFlow/742e624e9bef955124094956de394c9eb458d54e/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/linean/StateReducerFlow/742e624e9bef955124094956de394c9eb458d54e/app/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/linean/StateReducerFlow/742e624e9bef955124094956de394c9eb458d54e/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/linean/StateReducerFlow/742e624e9bef955124094956de394c9eb458d54e/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/linean/StateReducerFlow/742e624e9bef955124094956de394c9eb458d54e/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/linean/StateReducerFlow/742e624e9bef955124094956de394c9eb458d54e/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/linean/StateReducerFlow/742e624e9bef955124094956de394c9eb458d54e/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/linean/StateReducerFlow/742e624e9bef955124094956de394c9eb458d54e/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/linean/StateReducerFlow/742e624e9bef955124094956de394c9eb458d54e/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | StateReducerFlow
3 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
--------------------------------------------------------------------------------
/app/src/test/java/com/example/statereducerflow/MoviesListViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package com.example.statereducerflow
2 |
3 | import com.example.statereducerflow.data.Movie
4 | import com.example.statereducerflow.logic.FetchMovies
5 | import com.example.statereducerflow.ui.MoviesListEvent
6 | import com.example.statereducerflow.ui.MoviesListEvent.*
7 | import com.example.statereducerflow.ui.MoviesListState
8 | import com.example.statereducerflow.ui.MoviesListViewModel
9 | import io.mockk.coVerify
10 | import io.mockk.mockk
11 | import junit.framework.TestCase.assertEquals
12 | import junit.framework.TestCase.assertNotSame
13 | import org.junit.Before
14 | import org.junit.Rule
15 | import org.junit.Test
16 |
17 | private fun Movie(id: Int = 1) = Movie(id, "name", "image")
18 |
19 | class MoviesListViewModelTest {
20 |
21 | @get:Rule
22 | val testCoroutineRule = TestCoroutineRule()
23 |
24 | private val fetchMovies = mockk()
25 | private lateinit var state: StateReducerFlow
26 |
27 | @Before
28 | fun setUp() {
29 | val viewModel = MoviesListViewModel(fetchMovies)
30 | state = viewModel.state
31 | }
32 |
33 | @Test
34 | fun `fetches movies after screen is started`() {
35 | state.handleEvent(ScreenStarted)
36 |
37 | coVerify { fetchMovies() }
38 | assertEquals(true, state.value.isLoading)
39 | }
40 |
41 | @Test
42 | fun `fetches movies after refresh clicked`() {
43 | state.handleEvent(RefreshClicked)
44 |
45 | coVerify { fetchMovies() }
46 | assertEquals(true, state.value.isLoading)
47 | }
48 |
49 | @Test
50 | fun `displays movies after loaded`() {
51 | val testMovies = (0..2).map(::Movie)
52 | state.handleEvent(MoviesLoaded(testMovies))
53 |
54 | assertEquals(false, state.value.isLoading)
55 | assertEquals(testMovies, state.value.movies)
56 | }
57 |
58 | @Test
59 | fun `toggles selection after movie clicked`() {
60 | fun isSelected() = state.value.movies.first().isSelected
61 | val testMovie = Movie()
62 |
63 | state.handleEvent(MoviesLoaded(listOf(testMovie)))
64 | assertEquals(false, isSelected())
65 |
66 | state.handleEvent(MovieClicked(testMovie))
67 | assertEquals(true, isSelected())
68 |
69 | state.handleEvent(MovieClicked(testMovie))
70 | assertEquals(false, isSelected())
71 | }
72 |
73 | @Test
74 | fun `persist selection across updates`() {
75 | fun isSelected() = state.value.movies.first().isSelected
76 | val testMovie = Movie()
77 |
78 | state.handleEvent(MoviesLoaded(listOf(testMovie)))
79 | assertEquals(false, isSelected())
80 |
81 | state.handleEvent(MovieClicked(testMovie))
82 | assertEquals(true, isSelected())
83 |
84 | state.handleEvent(MoviesLoaded(listOf(testMovie)))
85 | assertEquals(true, isSelected())
86 | }
87 |
88 | @Test
89 | fun `shuffles movies after shuffle clicked`() {
90 | val movies = (0..50).map(::Movie)
91 | state.handleEvent(MoviesLoaded(movies))
92 | assertEquals(movies, state.value.movies)
93 |
94 | state.handleEvent(ShuffleClicked)
95 |
96 | assertEquals(movies.size, state.value.movies.size)
97 | assertNotSame(movies, state.value.movies)
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/app/src/test/java/com/example/statereducerflow/TestCoroutineRule.kt:
--------------------------------------------------------------------------------
1 | package com.example.statereducerflow
2 |
3 | import kotlinx.coroutines.Dispatchers
4 | import kotlinx.coroutines.ExperimentalCoroutinesApi
5 | import kotlinx.coroutines.test.UnconfinedTestDispatcher
6 | import kotlinx.coroutines.test.resetMain
7 | import kotlinx.coroutines.test.setMain
8 | import org.junit.rules.TestWatcher
9 | import org.junit.runner.Description
10 |
11 | @OptIn(ExperimentalCoroutinesApi::class)
12 | class TestCoroutineRule : TestWatcher() {
13 |
14 | val dispatcher = UnconfinedTestDispatcher(name = "TestCoroutineRuleDispatcher")
15 |
16 | override fun starting(description: Description?) {
17 | super.starting(description)
18 | Dispatchers.setMain(dispatcher)
19 | }
20 |
21 | override fun finished(description: Description?) {
22 | super.finished(description)
23 | Dispatchers.resetMain()
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/build.gradle:
--------------------------------------------------------------------------------
1 | buildscript {
2 | ext {
3 | compose_version = '1.1.1'
4 | kotlin_version = '1.6.10'
5 | }
6 | }// Top-level build file where you can add configuration options common to all sub-projects/modules.
7 | plugins {
8 | id 'com.android.application' version '7.1.2' apply false
9 | id 'com.android.library' version '7.1.2' apply false
10 | id 'org.jetbrains.kotlin.android' version "$kotlin_version" apply false
11 | id 'org.jetbrains.kotlin.plugin.serialization' version "$kotlin_version" apply false
12 | }
13 |
14 | task clean(type: Delete) {
15 | delete rootProject.buildDir
16 | }
--------------------------------------------------------------------------------
/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/linean/StateReducerFlow/742e624e9bef955124094956de394c9eb458d54e/demo.gif
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app"s APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/linean/StateReducerFlow/742e624e9bef955124094956de394c9eb458d54e/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Thu Mar 31 11:25:16 CEST 2022
2 | distributionBase=GRADLE_USER_HOME
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
4 | distributionPath=wrapper/dists
5 | zipStorePath=wrapper/dists
6 | zipStoreBase=GRADLE_USER_HOME
7 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | gradlePluginPortal()
4 | google()
5 | mavenCentral()
6 | }
7 | }
8 | dependencyResolutionManagement {
9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
10 | repositories {
11 | google()
12 | mavenCentral()
13 | }
14 | }
15 | rootProject.name = "StateReducerFlow"
16 | include ':app'
17 |
--------------------------------------------------------------------------------