├── .gitignore
├── .travis.yml
├── LICENSE
├── README.md
├── build.gradle.kts
├── buildSrc
├── build.gradle.kts
└── src
│ └── main
│ └── kotlin
│ ├── Deps.kt
│ └── Pom.kt
├── docs
└── diagrams
│ ├── flowchart-composite-knot.png
│ ├── flowchart-knot.png
│ └── flowchart.graphml
├── gradle.properties
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── knot3-android-sample
├── build.gradle.kts
└── src
│ └── main
│ ├── AndroidManifest.xml
│ ├── kotlin
│ └── de
│ │ └── halfbit
│ │ └── knot3
│ │ └── sample
│ │ ├── MainActivity.kt
│ │ ├── SelectorFragment.kt
│ │ ├── books
│ │ ├── BooksFragment.kt
│ │ ├── BooksView.kt
│ │ ├── BooksViewBinder.kt
│ │ ├── BooksViewModel.kt
│ │ ├── actions
│ │ │ └── LoadBooksAction.kt
│ │ └── model
│ │ │ ├── Action.kt
│ │ │ ├── Change.kt
│ │ │ ├── Event.kt
│ │ │ ├── State.kt
│ │ │ └── types
│ │ │ └── Book.kt
│ │ ├── books2
│ │ ├── BooksFragment.kt
│ │ ├── BooksView.kt
│ │ ├── BooksViewBinder.kt
│ │ ├── BooksViewModel.kt
│ │ ├── actions
│ │ │ └── LoadBooksAction.kt
│ │ ├── delegates
│ │ │ ├── ClearButtonDelegate.kt
│ │ │ └── LoadButtonDelegate.kt
│ │ └── model
│ │ │ ├── Action.kt
│ │ │ ├── Event.kt
│ │ │ ├── State.kt
│ │ │ └── types
│ │ │ └── Book.kt
│ │ └── common
│ │ └── ViewBinder.kt
│ └── res
│ ├── drawable-v24
│ └── ic_launcher_foreground.xml
│ ├── drawable
│ └── ic_launcher_background.xml
│ ├── layout
│ ├── activity_main.xml
│ ├── fragment_books.xml
│ ├── fragment_selector.xml
│ ├── part_books_content.xml
│ ├── part_books_empty.xml
│ ├── part_books_error.xml
│ └── part_books_loading.xml
│ ├── mipmap-anydpi-v26
│ ├── ic_launcher.xml
│ └── ic_launcher_round.xml
│ ├── mipmap-hdpi
│ ├── ic_launcher.png
│ └── ic_launcher_round.png
│ ├── mipmap-mdpi
│ ├── ic_launcher.png
│ └── ic_launcher_round.png
│ ├── mipmap-xhdpi
│ ├── ic_launcher.png
│ └── ic_launcher_round.png
│ ├── mipmap-xxhdpi
│ ├── ic_launcher.png
│ └── ic_launcher_round.png
│ ├── mipmap-xxxhdpi
│ ├── ic_launcher.png
│ └── ic_launcher_round.png
│ └── values
│ ├── colors.xml
│ ├── strings.xml
│ └── styles.xml
├── knot3
├── build.gradle.kts
└── src
│ ├── main
│ └── kotlin
│ │ └── de
│ │ └── halfbit
│ │ └── knot3
│ │ ├── CompositeKnot.kt
│ │ ├── CompositeKnotBuilder.kt
│ │ ├── Knot.kt
│ │ ├── KnotBuilder.kt
│ │ ├── TestCompositeKnot.kt
│ │ └── partial
│ │ ├── PartialReducer.kt
│ │ └── StateOnlyPartialReducer.kt
│ └── test
│ ├── kotlin
│ └── de
│ │ └── halfbit
│ │ └── knot3
│ │ ├── CompositeKnotColdSourceTest.kt
│ │ ├── CompositeKnotRequireStateTest.kt
│ │ ├── CompositeKnotTest.kt
│ │ ├── CompositeKnotWhenStateTest.kt
│ │ ├── ConcurrentStateMutations.kt
│ │ ├── DelegateInterceptChangeTest.kt
│ │ ├── DelegateTest.kt
│ │ ├── KnotColdSourceTest.kt
│ │ ├── KnotEffectTest.kt
│ │ ├── KnotInterceptActionTest.kt
│ │ ├── KnotInterceptChangeTest.kt
│ │ ├── KnotInterceptStateTest.kt
│ │ ├── KnotMultipleActionsTest.kt
│ │ ├── KnotRequireStateTest.kt
│ │ ├── KnotSingleActionTest.kt
│ │ ├── KnotTest.kt
│ │ ├── KnotWatchActionTest.kt
│ │ ├── KnotWatchChangeTest.kt
│ │ ├── KnotWatchStateTest.kt
│ │ ├── KnotWhenStateTest.kt
│ │ ├── PartialStateOnlyTest.kt
│ │ ├── PartialStateWithActionsTest.kt
│ │ ├── TestCompositeKnotTest.kt
│ │ ├── partial
│ │ ├── PartialReducerTest.kt
│ │ └── StateOnlyPartialReducerTest.kt
│ │ └── utils
│ │ ├── RxPluginsException.kt
│ │ └── SchedulerTester.kt
│ └── resources
│ └── mockito-extensions
│ └── org.mockito.plugins.MockMaker
└── settings.gradle.kts
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | *.hprof
3 |
4 | .gradle
5 | .DS_Store
6 | .externalNativeBuild
7 |
8 | local.properties
9 |
10 | .idea/
11 | build/
12 | captures/
13 | out/
14 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | dist: trusty
2 | language: java
3 | install: true
4 |
5 | jdk:
6 | - oraclejdk8
7 |
8 | before_cache:
9 | - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
10 | - rm -fr $HOME/.gradle/caches/*/plugin-resolution/
11 |
12 | cache:
13 | directories:
14 | - $HOME/.gradle/caches/
15 | - $HOME/.gradle/wrapper/
16 |
17 | after_success:
18 | - bash <(curl -s https://codecov.io/bash)
19 |
20 | script:
21 | - ./gradlew :knot:build
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://central.sonatype.com/artifact/de.halfbit/knot3)
2 | 
3 | [](http://www.apache.org/licenses/LICENSE-2.0)
4 |
5 | # 🧶 Knot
6 |
7 | Concise reactive state container library for Android applications.
8 |
9 | # Concept
10 |
11 | Knot helps managing application state by reacting on events and performing asynchronous actions in a structured way. There are five core concepts Knot defines: `State`, `Change`, `Action`, `Reducer` and `Effect`.
12 |
13 |
14 |
15 | `State` represents an immutable state of an application. It can be a state of a screen or a state of an internal statefull headless component.
16 |
17 | `Change` is an immutable data object with an optional payload intended for changing the `State`. A `Change` can be produced from an external source or be a result of execution of an `Action`.
18 |
19 | `Action` is a synchronous or an asynchronous operation which, when completed, can – but doesn't have to – emit a new `Change`.
20 |
21 | `Reducer` is a function that takes the previous `State` and a `Change` as arguments and returns the new `State` and an optional `Action` wrapped by the `Effect` class. `Reducer` in Knot is designed to stay side-effects free because each side-effect can be turned into an `Action` and returned from the reducer function together with a new state in a pure way.
22 |
23 | `Effect` is a convenient wrapper class containing the new `State` and an optional `Action`. If `Action` is present, Knot will perform it and provide resulting `Change` (if any) back to the `Reducer`.
24 |
25 | In addition to that each Knot can subscribe to `Events` coming from external sources and turn them into `Changes` for further processing.
26 |
27 | # Getting Started
28 |
29 | The example below declares a Knot capable of loading data, handling *Success* and *Failure* loading results and reloading data automatically when an external *"data changed"* signal gets received. It also logs all `State` mutations as well as all processed `Changes` and `Actions` in console.
30 |
31 | ```kotlin
32 | sealed class State {
33 | object Initial : State()
34 | object Loading : State()
35 | data class Content(val data: String) : State()
36 | data class Failed(val error: Throwable) : State()
37 | }
38 |
39 | sealed class Change {
40 | object Load : Change() {
41 | data class Success(val data: String) : Change()
42 | data class Failure(val error: Throwable) : Change()
43 | }
44 | }
45 |
46 | sealed class Action {
47 | object Load : Action()
48 | }
49 |
50 | val knot = knot {
51 | state {
52 | initial = State.Initial
53 | }
54 | changes {
55 | reduce { change ->
56 | when (change) {
57 | is Change.Load -> State.Loading + Action.Load
58 | is Change.Load.Success -> State.Content(data).only
59 | is Change.Load.Failure -> State.Failed(error).only
60 | }
61 | }
62 | }
63 | actions {
64 | perform {
65 | switchMapSingle {
66 | loadData()
67 | .map { Change.Load.Success(it) }
68 | .onErrorReturn { Change.Load.Failure(it) }
69 | }
70 | }
71 | }
72 | events {
73 | source {
74 | dataChangeObserver.signal.map { Change.Load }
75 | }
76 | }
77 | }
78 |
79 | val states = knot.state.test()
80 | knot.change.accept(Change.Load)
81 |
82 | states.assertValues(
83 | State.Initial,
84 | State.Loading,
85 | State.Content("data")
86 | )
87 | ```
88 |
89 | Notice how inside the `reduce` function a new `State` can be combined with an `Action` using `+` operator. If only the `State` value should be returned from the reducer, the `.only` suffix is added to the `State`.
90 |
91 | # Composition
92 |
93 | If your knot becomes complex and you want to improve its readability and maintainability, you may consider to write a composite knot. You start composition by grouping related functionality into, in a certain sense, indecomposable pieces called `Delegates`.
94 |
95 |
96 |
97 | Each `Delegate` is isolated from the other `Delegates`. It defines its own set of `Changes`, `Actions` and `Reducers`. It's only the `State`, what is shared between the `Delegates`. In that respect each `Delegate` can be seen as a separate `Knot` working on a shared `State`. Once all `Delegates` are defined, they can be composed together and provided to `CompositeKnot` which implements standard `Knot` interface. For more information check out [Composite ViewModel](https://www.halfbit.de/posts/composite-viewmodel/) post.
98 |
99 | # Documentation
100 | 1. [Knot Sample App](https://github.com/beworker/knot/tree/master/knot3-android-sample/src/main/kotlin/de/halfbit/knot3/sample) is the first place to look at.
101 | 2. [Async Actions](https://www.halfbit.de/posts/async-actions-in-knot/) to learn how to perform and cancel asynchronous actions.
102 | 3. [External Events](https://www.halfbit.de/posts/external-events-in-knot/) to learn how to observe and handle external events.
103 | 4. [Terminal events in Actions section](https://github.com/beworker/knot/wiki/Terminal-events-in-Actions-section)
104 | 5. [Composite ViewModel](https://www.halfbit.de/posts/composite-viewmodel/) to learn more about composition.
105 | 6. [Troubleshooting](https://github.com/beworker/knot/wiki/Troubleshooting)
106 |
107 | # Other examples
108 | - [Co2Monitor sample app](https://github.com/beworker/co2monitor/blob/master/android-client/main-dashboard/src/main/java/de/halfbit/co2monitor/main/dashboard/DashboardViewModel.kt)
109 |
110 | # Why Knot?
111 |
112 | * Predictable - state is the single source of truth.
113 | * Side-effect free reducer - by design.
114 | * Scalable - single knots can be combined together to build more complex application logic.
115 | * Composable - complex knots can be composed out of delegates grouped by related functionality.
116 | * Structured - easy to read and write DSL for writing better structured and less buggy code.
117 | * Concise - it has minimalistic API and compact implementation.
118 | * Testable - reducers and transformers are easy to test.
119 | * Production ready - Knot is used in production.
120 | * Why not?
121 |
122 | # RxJava3 Binaries [](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22de.halfbit%22%20a%3A%22knot3%22)
123 | ```kotlin
124 | allprojects {
125 | repositories {
126 | mavenCentral()
127 | }
128 | }
129 | dependencies {
130 | implementation "de.halfbit:knot3:"
131 |
132 | // Because Knot is not released for each and every RxJava version,
133 | // it is recommended you also explicitly depend on RxJava's latest
134 | // version for bug fixes and new features.
135 | implementation 'io.reactivex.rxjava3:rxjava:3.0.4'
136 | }
137 | ```
138 |
139 | # RxJava2 Binaries [](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22de.halfbit%22%20a%3A%22knot%22)
140 |
141 | ```kotlin
142 | allprojects {
143 | repositories {
144 | mavenCentral()
145 | }
146 | }
147 | dependencies {
148 | implementation "de.halfbit:knot:"
149 |
150 | // Becase Knot is not released for each and every RxJava version,
151 | // it is recommended you also explicitly depend on RxJava's latest
152 | // version for bug fixes and new features.
153 | implementation 'io.reactivex.rxjava2:rxjava:2.2.19'
154 | }
155 | ```
156 |
157 | # Inspiration
158 | Knot was inspired by two awesome projects
159 | * Krate https://github.com/gustavkarlsson/krate
160 | * Redux-loop https://github.com/redux-loop/redux-loop
161 |
162 | # License
163 | ```
164 | Copyright 2019, 2020 Sergej Shafarenka, www.halfbit.de
165 |
166 | Licensed under the Apache License, Version 2.0 (the "License");
167 | you may not use this file except in compliance with the License.
168 | You may obtain a copy of the License at
169 |
170 | http://www.apache.org/licenses/LICENSE-2.0
171 |
172 | Unless required by applicable law or agreed to in writing, software
173 | distributed under the License is distributed on an "AS IS" BASIS,
174 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
175 | See the License for the specific language governing permissions and
176 | limitations under the License.
177 | ```
178 |
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | kotlin("jvm") version Deps.Version.kotlin apply false
3 | id("org.jetbrains.dokka") version Deps.Version.dokka apply false
4 | id("com.android.application") version Deps.Version.agp apply false
5 | }
6 |
7 | allprojects {
8 | group = "de.halfbit"
9 | version = "3.2-rc1"
10 |
11 | repositories {
12 | mavenCentral()
13 | jcenter()
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/buildSrc/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | `kotlin-dsl`
3 | }
4 |
5 | repositories {
6 | jcenter()
7 | }
8 |
--------------------------------------------------------------------------------
/buildSrc/src/main/kotlin/Deps.kt:
--------------------------------------------------------------------------------
1 | object Deps {
2 |
3 | object Version {
4 | const val kotlin = "1.3.72"
5 | const val dokka = "0.9.17"
6 | const val jvmTarget = "1.8"
7 | const val agp = "4.0.0"
8 | }
9 |
10 | const val rxJava = "io.reactivex.rxjava3:rxjava:3.0.4"
11 | const val kotlinJdk = "stdlib-jdk8"
12 | const val junit = "junit:junit:4.12"
13 | const val truth = "com.google.truth:truth:0.44"
14 | const val mockito = "org.mockito:mockito-core:2.28.2"
15 | const val mockitoKotlin = "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
16 | }
--------------------------------------------------------------------------------
/buildSrc/src/main/kotlin/Pom.kt:
--------------------------------------------------------------------------------
1 | import org.gradle.api.Project
2 |
3 | object Pom {
4 | const val url = "http://www.halfbit.de"
5 |
6 | object License {
7 | const val name = "The Apache License, Version 2.0"
8 | const val url = "http://www.apache.org/licenses/LICENSE-2.0.txt"
9 | }
10 |
11 | object Developer {
12 | const val id = "beworker"
13 | const val name = "Sergej Shafarenka"
14 | const val email = "info@halfbit.de"
15 | }
16 |
17 | object Github {
18 | const val cloneUrl = "scm:git:ssh://github.com:beworker/knot.git"
19 | const val url = "https://github.com/beworker/knot"
20 | }
21 |
22 | object MavenCentral {
23 | const val name = "central"
24 | const val url = "https://oss.sonatype.org/service/local/staging/deploy/maven2"
25 | }
26 | }
27 |
28 | fun Project.getNexusUser() = this.findProperty("NEXUS_USERNAME") as? String ?: ""
29 | fun Project.getNexusPassword() = this.findProperty("NEXUS_PASSWORD") as? String ?: ""
30 | fun Project.hasSigningKey() = this.hasProperty("signing.keyId")
--------------------------------------------------------------------------------
/docs/diagrams/flowchart-composite-knot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sergejsha/knot/371abc88a094960446c3727f30150b567ca41bc3/docs/diagrams/flowchart-composite-knot.png
--------------------------------------------------------------------------------
/docs/diagrams/flowchart-knot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sergejsha/knot/371abc88a094960446c3727f30150b567ca41bc3/docs/diagrams/flowchart-knot.png
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | kotlin.code.style=official
2 | systemProp.org.gradle.internal.publish.checksums.insecure=true
3 | android.useAndroidX=true
4 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sergejsha/knot/371abc88a094960446c3727f30150b567ca41bc3/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
6 |
--------------------------------------------------------------------------------
/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 | # http://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 | # Determine the Java command to use to start the JVM.
86 | if [ -n "$JAVA_HOME" ] ; then
87 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
88 | # IBM's JDK on AIX uses strange locations for the executables
89 | JAVACMD="$JAVA_HOME/jre/sh/java"
90 | else
91 | JAVACMD="$JAVA_HOME/bin/java"
92 | fi
93 | if [ ! -x "$JAVACMD" ] ; then
94 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
95 |
96 | Please set the JAVA_HOME variable in your environment to match the
97 | location of your Java installation."
98 | fi
99 | else
100 | JAVACMD="java"
101 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
102 |
103 | Please set the JAVA_HOME variable in your environment to match the
104 | location of your Java installation."
105 | fi
106 |
107 | # Increase the maximum file descriptors if we can.
108 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
109 | MAX_FD_LIMIT=`ulimit -H -n`
110 | if [ $? -eq 0 ] ; then
111 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
112 | MAX_FD="$MAX_FD_LIMIT"
113 | fi
114 | ulimit -n $MAX_FD
115 | if [ $? -ne 0 ] ; then
116 | warn "Could not set maximum file descriptor limit: $MAX_FD"
117 | fi
118 | else
119 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
120 | fi
121 | fi
122 |
123 | # For Darwin, add options to specify how the application appears in the dock
124 | if $darwin; then
125 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
126 | fi
127 |
128 | # For Cygwin, switch paths to Windows format before running java
129 | if $cygwin ; then
130 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
131 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
132 | JAVACMD=`cygpath --unix "$JAVACMD"`
133 |
134 | # We build the pattern for arguments to be converted via cygpath
135 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
136 | SEP=""
137 | for dir in $ROOTDIRSRAW ; do
138 | ROOTDIRS="$ROOTDIRS$SEP$dir"
139 | SEP="|"
140 | done
141 | OURCYGPATTERN="(^($ROOTDIRS))"
142 | # Add a user-defined pattern to the cygpath arguments
143 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
144 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
145 | fi
146 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
147 | i=0
148 | for arg in "$@" ; do
149 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
150 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
151 |
152 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
153 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
154 | else
155 | eval `echo args$i`="\"$arg\""
156 | fi
157 | i=$((i+1))
158 | done
159 | case $i in
160 | (0) set -- ;;
161 | (1) set -- "$args0" ;;
162 | (2) set -- "$args0" "$args1" ;;
163 | (3) set -- "$args0" "$args1" "$args2" ;;
164 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
165 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
166 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
167 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
168 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
169 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
170 | esac
171 | fi
172 |
173 | # Escape application args
174 | save () {
175 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
176 | echo " "
177 | }
178 | APP_ARGS=$(save "$@")
179 |
180 | # Collect all arguments for the java command, following the shell quoting and substitution rules
181 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
182 |
183 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
184 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
185 | cd "$(dirname "$0")"
186 | fi
187 |
188 | exec "$JAVACMD" "$@"
189 |
--------------------------------------------------------------------------------
/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 http://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 Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
33 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
34 |
35 | @rem Find java.exe
36 | if defined JAVA_HOME goto findJavaFromJavaHome
37 |
38 | set JAVA_EXE=java.exe
39 | %JAVA_EXE% -version >NUL 2>&1
40 | if "%ERRORLEVEL%" == "0" goto init
41 |
42 | echo.
43 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
44 | echo.
45 | echo Please set the JAVA_HOME variable in your environment to match the
46 | echo location of your Java installation.
47 |
48 | goto fail
49 |
50 | :findJavaFromJavaHome
51 | set JAVA_HOME=%JAVA_HOME:"=%
52 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
53 |
54 | if exist "%JAVA_EXE%" goto init
55 |
56 | echo.
57 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
58 | echo.
59 | echo Please set the JAVA_HOME variable in your environment to match the
60 | echo location of your Java installation.
61 |
62 | goto fail
63 |
64 | :init
65 | @rem Get command-line arguments, handling Windows variants
66 |
67 | if not "%OS%" == "Windows_NT" goto win9xME_args
68 |
69 | :win9xME_args
70 | @rem Slurp the command line arguments.
71 | set CMD_LINE_ARGS=
72 | set _SKIP=2
73 |
74 | :win9xME_args_slurp
75 | if "x%~1" == "x" goto execute
76 |
77 | set CMD_LINE_ARGS=%*
78 |
79 | :execute
80 | @rem Setup the command line
81 |
82 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
83 |
84 | @rem Execute Gradle
85 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
86 |
87 | :end
88 | @rem End local scope for the variables with windows NT shell
89 | if "%ERRORLEVEL%"=="0" goto mainEnd
90 |
91 | :fail
92 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
93 | rem the _cmd.exe /c_ return code!
94 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
95 | exit /b 1
96 |
97 | :mainEnd
98 | if "%OS%"=="Windows_NT" endlocal
99 |
100 | :omega
101 |
--------------------------------------------------------------------------------
/knot3-android-sample/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.android.application")
3 | kotlin("android")
4 | kotlin("kapt")
5 | }
6 |
7 | repositories {
8 | google()
9 | jcenter()
10 | }
11 |
12 | android {
13 | compileSdkVersion(29)
14 | defaultConfig {
15 | applicationId = "de.halfbit.knot.sample"
16 | minSdkVersion(23)
17 | targetSdkVersion(29)
18 | versionCode = 1
19 | versionName = "1.0"
20 | }
21 | compileOptions {
22 | sourceCompatibility = JavaVersion.VERSION_1_8
23 | targetCompatibility = JavaVersion.VERSION_1_8
24 | }
25 | kotlinOptions {
26 | jvmTarget = Deps.Version.jvmTarget
27 | }
28 | sourceSets.all { java.srcDir("src/$name/kotlin") }
29 | }
30 |
31 | dependencies {
32 | implementation(kotlin(Deps.kotlinJdk))
33 | implementation("androidx.appcompat:appcompat:1.1.0")
34 | implementation("androidx.core:core-ktx:1.3.0")
35 | implementation("androidx.constraintlayout:constraintlayout:1.1.3")
36 |
37 | val lifecycleVersion = "2.2.0"
38 | implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion")
39 | kapt("androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion")
40 |
41 | implementation(project(":knot3"))
42 | implementation("io.reactivex.rxjava3:rxjava:3.0.4")
43 | implementation("io.reactivex.rxjava3:rxandroid:3.0.0")
44 | implementation("io.reactivex.rxjava3:rxkotlin:3.0.0")
45 |
46 | testImplementation(Deps.junit)
47 | testImplementation(Deps.truth)
48 | }
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/kotlin/de/halfbit/knot3/sample/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3.sample
2 |
3 | import android.os.Bundle
4 | import androidx.appcompat.app.AppCompatActivity
5 |
6 | class MainActivity : AppCompatActivity() {
7 | override fun onCreate(savedInstanceState: Bundle?) {
8 | super.onCreate(savedInstanceState)
9 | setContentView(R.layout.activity_main)
10 | if (savedInstanceState == null) {
11 | supportFragmentManager.beginTransaction()
12 | .add(R.id.rootFrameLayout, SelectorFragment())
13 | .commit()
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/kotlin/de/halfbit/knot3/sample/SelectorFragment.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3.sample
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 |
9 | typealias OrdinaryKnotFragment = de.halfbit.knot3.sample.books.BooksFragment
10 | typealias CompositeKnotFragment = de.halfbit.knot3.sample.books2.BooksFragment
11 |
12 | class SelectorFragment : Fragment() {
13 |
14 | override fun onCreateView(
15 | inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
16 | ): View? = inflater.inflate(R.layout.fragment_selector, container, false)
17 |
18 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
19 | super.onViewCreated(view, savedInstanceState)
20 |
21 | view.findViewById(R.id.books).setOnClickListener {
22 | navigateTo(OrdinaryKnotFragment())
23 | }
24 |
25 | view.findViewById(R.id.books2).setOnClickListener {
26 | navigateTo(CompositeKnotFragment())
27 | }
28 | }
29 |
30 | private fun navigateTo(fragment: Fragment) {
31 | requireActivity().supportFragmentManager
32 | .beginTransaction()
33 | .replace(R.id.rootFrameLayout, fragment)
34 | .addToBackStack(null)
35 | .commit()
36 | }
37 | }
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/kotlin/de/halfbit/knot3/sample/books/BooksFragment.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3.sample.books
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 androidx.lifecycle.ViewModel
9 | import androidx.lifecycle.ViewModelProvider
10 | import de.halfbit.knot3.sample.R
11 | import de.halfbit.knot3.sample.common.ViewBinder
12 | import io.reactivex.rxjava3.disposables.CompositeDisposable
13 |
14 | class BooksFragment : Fragment() {
15 |
16 | private val disposable = CompositeDisposable()
17 | private lateinit var viewBinder: ViewBinder
18 |
19 | override fun onCreateView(
20 | inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
21 | ): View? = inflater.inflate(R.layout.fragment_books, container, false)
22 |
23 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
24 | super.onViewCreated(view, savedInstanceState)
25 | viewBinder = BooksViewBinder(DefaultBookView(view), getViewModel())
26 | }
27 |
28 | override fun onStart() {
29 | super.onStart()
30 | viewBinder.bind(disposable)
31 | }
32 |
33 | override fun onStop() {
34 | disposable.clear()
35 | super.onStop()
36 | }
37 | }
38 |
39 | private inline fun Fragment.getViewModel(): VM =
40 | ViewModelProvider(this, BooksViewModelFactory).get(VM::class.java)
41 |
42 | private object BooksViewModelFactory : ViewModelProvider.Factory {
43 | override fun create(modelClass: Class): T =
44 | if (modelClass.isAssignableFrom(BooksViewModel::class.java)) {
45 | @Suppress("UNCHECKED_CAST")
46 | DefaultBooksViewModel() as T
47 | } else error("Unsupported model type: $modelClass")
48 | }
49 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/kotlin/de/halfbit/knot3/sample/books/BooksView.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3.sample.books
2 |
3 | import android.view.View
4 | import android.widget.TextView
5 | import de.halfbit.knot3.sample.R
6 | import de.halfbit.knot3.sample.books.model.Event
7 | import de.halfbit.knot3.sample.books.model.types.Book
8 | import io.reactivex.rxjava3.core.Observable
9 | import io.reactivex.rxjava3.subjects.PublishSubject
10 | import io.reactivex.rxjava3.subjects.Subject
11 |
12 | interface BooksView {
13 | val event: Observable
14 |
15 | fun showEmpty()
16 | fun showLoading()
17 | fun showBooks(books: List)
18 | fun showError(message: String)
19 | }
20 |
21 | internal class DefaultBookView(rootView: View) : BooksView {
22 |
23 | private val pageEmpty: View = rootView.findViewById(R.id.pageEmpty)
24 | private val pageLoading: View = rootView.findViewById(R.id.pageLoading)
25 | private val pageContent: View = rootView.findViewById(R.id.pageContent)
26 | private val pageError: View = rootView.findViewById(R.id.pageError)
27 | private val booksMessage: TextView = rootView.findViewById(R.id.booksMessage)
28 | private val errorMessage: TextView = rootView.findViewById(R.id.errorMessage)
29 |
30 | init {
31 | val loadListener = View.OnClickListener { event.onNext(Event.Load) }
32 | rootView.findViewById(R.id.tryAgainButton).setOnClickListener(loadListener)
33 | rootView.findViewById(R.id.reloadButton).setOnClickListener(loadListener)
34 | rootView.findViewById(R.id.loadButton).setOnClickListener(loadListener)
35 |
36 | val clearListener = View.OnClickListener { event.onNext(Event.Clear) }
37 | rootView.findViewById(R.id.clearButton).setOnClickListener(clearListener)
38 | }
39 |
40 | override val event: Subject = PublishSubject.create()
41 |
42 | override fun showEmpty() {
43 | pageEmpty.show()
44 | }
45 |
46 | override fun showLoading() {
47 | pageLoading.show()
48 | }
49 |
50 | override fun showBooks(books: List) {
51 | pageContent.show()
52 | val text = books.joinToString(separator = "\n") {
53 | "${it.title} (${it.year})"
54 | }
55 | booksMessage.text = "Books:\n$text"
56 | }
57 |
58 | override fun showError(message: String) {
59 | pageError.show()
60 | errorMessage.text = message
61 | }
62 |
63 | private fun View.show() {
64 | if (visibility != View.VISIBLE) {
65 | visibility = View.VISIBLE
66 | }
67 | if (this != pageContent) pageContent.hide()
68 | if (this != pageLoading) pageLoading.hide()
69 | if (this != pageError) pageError.hide()
70 | if (this != pageEmpty) pageEmpty.hide()
71 | }
72 |
73 | private fun View.hide() {
74 | if (visibility != View.INVISIBLE) {
75 | visibility = View.INVISIBLE
76 | }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/kotlin/de/halfbit/knot3/sample/books/BooksViewBinder.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3.sample.books
2 |
3 | import de.halfbit.knot3.sample.books.model.State
4 | import de.halfbit.knot3.sample.common.ViewBinder
5 | import io.reactivex.rxjava3.disposables.CompositeDisposable
6 | import io.reactivex.rxjava3.kotlin.ofType
7 | import io.reactivex.rxjava3.kotlin.plusAssign
8 |
9 | internal class BooksViewBinder(
10 | private val view: BooksView,
11 | private val viewModel: BooksViewModel
12 | ) : ViewBinder {
13 |
14 | override fun bind(disposable: CompositeDisposable) {
15 | view.bindEvents(disposable)
16 | viewModel.bindState(disposable)
17 | }
18 |
19 | private fun BooksView.bindEvents(disposable: CompositeDisposable) {
20 | disposable += event.subscribe(viewModel.event)
21 | }
22 |
23 | private fun BooksViewModel.bindState(disposable: CompositeDisposable) {
24 | disposable += state
25 | .ofType()
26 | .subscribe { view.showLoading() }
27 |
28 | disposable += state
29 | .ofType()
30 | .subscribe { view.showEmpty() }
31 |
32 | disposable += state
33 | .ofType()
34 | .map { it.books }
35 | .subscribe(view::showBooks)
36 |
37 | disposable += state
38 | .ofType()
39 | .map { it.message }
40 | .subscribe(view::showError)
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/kotlin/de/halfbit/knot3/sample/books/BooksViewModel.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3.sample.books
2 |
3 | import androidx.lifecycle.ViewModel
4 | import de.halfbit.knot3.knot
5 | import de.halfbit.knot3.sample.books.actions.DefaultLoadBooksAction
6 | import de.halfbit.knot3.sample.books.actions.LoadBooksAction
7 | import de.halfbit.knot3.sample.books.model.Action
8 | import de.halfbit.knot3.sample.books.model.Change
9 | import de.halfbit.knot3.sample.books.model.Event
10 | import de.halfbit.knot3.sample.books.model.State
11 | import de.halfbit.knot3.sample.books.model.types.Book
12 | import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
13 | import io.reactivex.rxjava3.core.Observable
14 | import io.reactivex.rxjava3.core.Scheduler
15 | import io.reactivex.rxjava3.functions.Consumer
16 |
17 | abstract class BooksViewModel : ViewModel() {
18 | abstract val state: Observable
19 | abstract val event: Consumer
20 | }
21 |
22 | internal class DefaultBooksViewModel(
23 | private val loadBooksAction: LoadBooksAction = DefaultLoadBooksAction(),
24 | private val observeOnScheduler: Scheduler = AndroidSchedulers.mainThread()
25 | ) : BooksViewModel() {
26 |
27 | override val state: Observable
28 | get() = knot.state
29 |
30 | override val event: Consumer =
31 | Consumer { knot.change.accept(it.toChange()) }
32 |
33 | private val knot = knot {
34 | state {
35 | initial = State.Empty
36 | observeOn = observeOnScheduler
37 | }
38 | changes {
39 | reduce { change ->
40 | when (change) {
41 | Change.Load -> when (this) {
42 | State.Empty,
43 | is State.Content,
44 | is State.Error -> State.Loading + Action.Load
45 | else -> only
46 | }
47 |
48 | is Change.Load.Success -> when (this) {
49 | State.Loading -> State.Content(change.books).only
50 | else -> unexpected(change)
51 | }
52 |
53 | is Change.Load.Failure -> when (this) {
54 | State.Loading -> State.Error(change.message).only
55 | else -> unexpected(change)
56 | }
57 |
58 | Change.Clear -> when (this) {
59 | is State.Content -> State.Empty.only
60 | is State.Empty -> only
61 | else -> unexpected(change)
62 | }
63 | }
64 | }
65 | }
66 | actions {
67 | perform {
68 | switchMapSingle {
69 | loadBooksAction.perform()
70 | .map { it.toChange() }
71 | }
72 | }
73 | }
74 | }
75 |
76 | override fun onCleared() {
77 | knot.dispose()
78 | super.onCleared()
79 | }
80 | }
81 |
82 | private fun Event.toChange(): Change = when (this) {
83 | is Event.Load -> Change.Load
84 | Event.Clear -> Change.Clear
85 | }
86 |
87 | private fun LoadBooksAction.Result.toChange() =
88 | when (this) {
89 | is LoadBooksAction.Result.Success ->
90 | Change.Load.Success(books.map { it.toBook() })
91 | is LoadBooksAction.Result.Failure.Network ->
92 | Change.Load.Failure("Network error. Check Internet connection and try again.")
93 | LoadBooksAction.Result.Failure.Generic ->
94 | Change.Load.Failure("Generic error, please try again.")
95 | }
96 |
97 | private fun LoadBooksAction.Book.toBook() = Book(title, year)
98 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/kotlin/de/halfbit/knot3/sample/books/actions/LoadBooksAction.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3.sample.books.actions
2 |
3 | import io.reactivex.rxjava3.core.Scheduler
4 | import io.reactivex.rxjava3.core.Single
5 | import io.reactivex.rxjava3.schedulers.Schedulers
6 | import java.util.concurrent.TimeUnit
7 |
8 | interface LoadBooksAction {
9 | fun perform(): Single
10 |
11 | sealed class Result {
12 | data class Success(val books: List) : Result()
13 | sealed class Failure : Result() {
14 | object Network : Failure()
15 | object Generic : Failure()
16 | }
17 | }
18 |
19 | data class Book(val title: String, val year: String)
20 | }
21 |
22 | class DefaultLoadBooksAction(
23 | private val ioScheduler: Scheduler = Schedulers.io()
24 | ) : LoadBooksAction {
25 | override fun perform(): Single =
26 | Single.just(books)
27 | .delay(4, TimeUnit.SECONDS)
28 | .subscribeOn(ioScheduler)
29 | .map {
30 | val failure = Math.random() < .35
31 | if (failure) {
32 | val network = Math.random() < .5
33 | if (network) LoadBooksAction.Result.Failure.Network
34 | else LoadBooksAction.Result.Failure.Generic
35 | } else LoadBooksAction.Result.Success(it)
36 | }
37 | }
38 |
39 | private val books = listOf(
40 | LoadBooksAction.Book("The Hobbit or There and Back Again", "1937"),
41 | LoadBooksAction.Book("Leaf by Niggle", "1945"),
42 | LoadBooksAction.Book("The Lay of Aotrou and Itroun", "1945"),
43 | LoadBooksAction.Book("Farmer Giles of Ham", "1949"),
44 | LoadBooksAction.Book("The Homecoming of Beorhtnoth Beorhthelm's Son", "1953"),
45 | LoadBooksAction.Book("The Lord of the Rings - The Fellowship of the Ring", "1954"),
46 | LoadBooksAction.Book("The Lord of the Rings - The Two Towers", "1954"),
47 | LoadBooksAction.Book("The Lord of the Rings - The Return of the King", "1955"),
48 | LoadBooksAction.Book("The Adventures of Tom Bombadil", "1962"),
49 | LoadBooksAction.Book("Tree and Leaf", "1964"),
50 | LoadBooksAction.Book("The Tolkien Reader", "1966"),
51 | LoadBooksAction.Book("The Road Goes Ever On", "1967"),
52 | LoadBooksAction.Book("Smith of Wootton Major", "1967")
53 | )
54 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/kotlin/de/halfbit/knot3/sample/books/model/Action.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3.sample.books.model
2 |
3 | sealed class Action {
4 | object Load : Action()
5 | }
6 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/kotlin/de/halfbit/knot3/sample/books/model/Change.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3.sample.books.model
2 |
3 | import de.halfbit.knot3.sample.books.model.types.Book
4 |
5 | sealed class Change {
6 | object Load : Change() {
7 | data class Success(val books: List) : Change()
8 | data class Failure(val message: String) : Change()
9 | }
10 |
11 | object Clear : Change()
12 | }
13 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/kotlin/de/halfbit/knot3/sample/books/model/Event.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3.sample.books.model
2 |
3 | sealed class Event {
4 | object Load : Event()
5 | object Clear : Event()
6 | }
7 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/kotlin/de/halfbit/knot3/sample/books/model/State.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3.sample.books.model
2 |
3 | import de.halfbit.knot3.sample.books.model.types.Book
4 |
5 | sealed class State {
6 | object Empty : State()
7 | object Loading : State()
8 | data class Content(val books: List) : State()
9 | data class Error(val message: String) : State()
10 | }
11 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/kotlin/de/halfbit/knot3/sample/books/model/types/Book.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3.sample.books.model.types
2 |
3 | data class Book(val title: String, val year: String)
4 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/kotlin/de/halfbit/knot3/sample/books2/BooksFragment.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3.sample.books2
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 androidx.lifecycle.ViewModel
9 | import androidx.lifecycle.ViewModelProvider
10 | import de.halfbit.knot3.sample.R
11 | import de.halfbit.knot3.sample.common.ViewBinder
12 | import io.reactivex.rxjava3.disposables.CompositeDisposable
13 |
14 | class BooksFragment : Fragment() {
15 |
16 | private val disposable = CompositeDisposable()
17 | private lateinit var viewBinder: ViewBinder
18 |
19 | override fun onCreateView(
20 | inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
21 | ): View? = inflater.inflate(R.layout.fragment_books, container, false)
22 |
23 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
24 | super.onViewCreated(view, savedInstanceState)
25 | viewBinder = BooksViewBinder(DefaultBookView(view), getViewModel())
26 | }
27 |
28 | override fun onStart() {
29 | super.onStart()
30 | viewBinder.bind(disposable)
31 | }
32 |
33 | override fun onStop() {
34 | disposable.clear()
35 | super.onStop()
36 | }
37 | }
38 |
39 | private inline fun Fragment.getViewModel(): VM =
40 | ViewModelProvider(this, BooksViewModelFactory).get(VM::class.java)
41 |
42 | private object BooksViewModelFactory : ViewModelProvider.Factory {
43 | override fun create(modelClass: Class): T =
44 | if (modelClass.isAssignableFrom(BooksViewModel::class.java)) {
45 | @Suppress("UNCHECKED_CAST")
46 | DefaultBooksViewModel() as T
47 | } else error("Unsupported model type: $modelClass")
48 | }
49 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/kotlin/de/halfbit/knot3/sample/books2/BooksView.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3.sample.books2
2 |
3 | import android.view.View
4 | import android.widget.TextView
5 | import de.halfbit.knot3.sample.R
6 | import de.halfbit.knot3.sample.books2.model.Event
7 | import de.halfbit.knot3.sample.books2.model.types.Book
8 | import io.reactivex.rxjava3.core.Observable
9 | import io.reactivex.rxjava3.subjects.PublishSubject
10 | import io.reactivex.rxjava3.subjects.Subject
11 |
12 | interface BooksView {
13 | val event: Observable
14 |
15 | fun showEmpty()
16 | fun showLoading()
17 | fun showBooks(books: List)
18 | fun showError(message: String)
19 | }
20 |
21 | internal class DefaultBookView(rootView: View) : BooksView {
22 |
23 | private val pageEmpty: View = rootView.findViewById(R.id.pageEmpty)
24 | private val pageLoading: View = rootView.findViewById(R.id.pageLoading)
25 | private val pageContent: View = rootView.findViewById(R.id.pageContent)
26 | private val pageError: View = rootView.findViewById(R.id.pageError)
27 | private val booksMessage: TextView = rootView.findViewById(R.id.booksMessage)
28 | private val errorMessage: TextView = rootView.findViewById(R.id.errorMessage)
29 |
30 | init {
31 | val loadListener = View.OnClickListener { event.onNext(Event.Load) }
32 | rootView.findViewById(R.id.tryAgainButton).setOnClickListener(loadListener)
33 | rootView.findViewById(R.id.reloadButton).setOnClickListener(loadListener)
34 | rootView.findViewById(R.id.loadButton).setOnClickListener(loadListener)
35 |
36 | val clearListener = View.OnClickListener { event.onNext(Event.Clear) }
37 | rootView.findViewById(R.id.clearButton).setOnClickListener(clearListener)
38 | }
39 |
40 | override val event: Subject = PublishSubject.create()
41 |
42 | override fun showEmpty() {
43 | pageEmpty.show()
44 | }
45 |
46 | override fun showLoading() {
47 | pageLoading.show()
48 | }
49 |
50 | override fun showBooks(books: List) {
51 | pageContent.show()
52 | val text = books.joinToString(separator = "\n") {
53 | "${it.title} (${it.year})"
54 | }
55 | booksMessage.text = "Books:\n$text"
56 | }
57 |
58 | override fun showError(message: String) {
59 | pageError.show()
60 | errorMessage.text = message
61 | }
62 |
63 | private fun View.show() {
64 | if (visibility != View.VISIBLE) {
65 | visibility = View.VISIBLE
66 | }
67 | if (this != pageContent) pageContent.hide()
68 | if (this != pageLoading) pageLoading.hide()
69 | if (this != pageError) pageError.hide()
70 | if (this != pageEmpty) pageEmpty.hide()
71 | }
72 |
73 | private fun View.hide() {
74 | if (visibility != View.INVISIBLE) {
75 | visibility = View.INVISIBLE
76 | }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/kotlin/de/halfbit/knot3/sample/books2/BooksViewBinder.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3.sample.books2
2 |
3 | import de.halfbit.knot3.sample.books2.model.State
4 | import de.halfbit.knot3.sample.common.ViewBinder
5 | import io.reactivex.rxjava3.disposables.CompositeDisposable
6 | import io.reactivex.rxjava3.kotlin.ofType
7 | import io.reactivex.rxjava3.kotlin.plusAssign
8 |
9 | class BooksViewBinder(
10 | private val view: BooksView,
11 | private val viewModel: BooksViewModel
12 | ) : ViewBinder {
13 |
14 | override fun bind(disposable: CompositeDisposable) {
15 | view.bindEvents(disposable)
16 | viewModel.bindState(disposable)
17 | }
18 |
19 | private fun BooksView.bindEvents(disposable: CompositeDisposable) {
20 | disposable += event.subscribe(viewModel.event)
21 | }
22 |
23 | private fun BooksViewModel.bindState(disposable: CompositeDisposable) {
24 | disposable += state
25 | .ofType()
26 | .subscribe { view.showLoading() }
27 |
28 | disposable += state
29 | .ofType()
30 | .subscribe { view.showEmpty() }
31 |
32 | disposable += state
33 | .ofType()
34 | .map { it.books }
35 | .subscribe(view::showBooks)
36 |
37 | disposable += state
38 | .ofType()
39 | .map { it.message }
40 | .subscribe(view::showError)
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/kotlin/de/halfbit/knot3/sample/books2/BooksViewModel.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3.sample.books2
2 |
3 | import androidx.lifecycle.ViewModel
4 | import de.halfbit.knot3.CompositeKnot
5 | import de.halfbit.knot3.compositeKnot
6 | import de.halfbit.knot3.sample.books2.delegates.ClearButtonDelegate
7 | import de.halfbit.knot3.sample.books2.delegates.LoadButtonDelegate
8 | import de.halfbit.knot3.sample.books2.model.Event
9 | import de.halfbit.knot3.sample.books2.model.State
10 | import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
11 | import io.reactivex.rxjava3.core.Observable
12 | import io.reactivex.rxjava3.core.Scheduler
13 | import io.reactivex.rxjava3.functions.Consumer
14 |
15 | abstract class BooksViewModel : ViewModel() {
16 | abstract val state: Observable
17 | abstract val event: Consumer
18 | }
19 |
20 | interface Delegate {
21 | fun CompositeKnot.register()
22 | fun CompositeKnot.onEvent(event: Event): Boolean = false
23 | }
24 |
25 | class DefaultBooksViewModel(
26 | private val delegates: List = listOf(
27 | LoadButtonDelegate(),
28 | ClearButtonDelegate()
29 | ),
30 | private val observeOnScheduler: Scheduler = AndroidSchedulers.mainThread()
31 | ) : BooksViewModel() {
32 |
33 | override val state: Observable
34 | get() = knot.state
35 |
36 | override val event: Consumer =
37 | Consumer { event ->
38 | delegates.any {
39 | with(it) { knot.onEvent(event) }
40 | }
41 | }
42 |
43 | private val knot = compositeKnot {
44 | state {
45 | initial = State.Empty
46 | observeOn = observeOnScheduler
47 | }
48 | }
49 |
50 | init {
51 | for (delegate in delegates) {
52 | with(delegate) { knot.register() }
53 | }
54 | knot.compose()
55 | }
56 |
57 | override fun onCleared() {
58 | knot.dispose()
59 | super.onCleared()
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/kotlin/de/halfbit/knot3/sample/books2/actions/LoadBooksAction.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3.sample.books2.actions
2 |
3 | import io.reactivex.rxjava3.core.Scheduler
4 | import io.reactivex.rxjava3.core.Single
5 | import io.reactivex.rxjava3.schedulers.Schedulers
6 | import java.util.concurrent.TimeUnit
7 |
8 | interface LoadBooksAction {
9 | fun perform(): Single
10 |
11 | sealed class Result {
12 | data class Success(val books: List) : Result()
13 | sealed class Failure : Result() {
14 | object Network : Failure()
15 | object Generic : Failure()
16 | }
17 | }
18 |
19 | data class Book(val title: String, val year: String)
20 | }
21 |
22 | class DefaultLoadBooksAction(
23 | private val ioScheduler: Scheduler = Schedulers.io()
24 | ) : LoadBooksAction {
25 | override fun perform(): Single =
26 | Single.just(books)
27 | .delay(4, TimeUnit.SECONDS)
28 | .subscribeOn(ioScheduler)
29 | .map {
30 | val failure = Math.random() < .35
31 | if (failure) {
32 | val network = Math.random() < .5
33 | if (network) LoadBooksAction.Result.Failure.Network
34 | else LoadBooksAction.Result.Failure.Generic
35 | } else LoadBooksAction.Result.Success(it)
36 | }
37 | }
38 |
39 | private val books = listOf(
40 | LoadBooksAction.Book("The Hobbit or There and Back Again", "1937"),
41 | LoadBooksAction.Book("Leaf by Niggle", "1945"),
42 | LoadBooksAction.Book("The Lay of Aotrou and Itroun", "1945"),
43 | LoadBooksAction.Book("Farmer Giles of Ham", "1949"),
44 | LoadBooksAction.Book("The Homecoming of Beorhtnoth Beorhthelm's Son", "1953"),
45 | LoadBooksAction.Book("The Lord of the Rings - The Fellowship of the Ring", "1954"),
46 | LoadBooksAction.Book("The Lord of the Rings - The Two Towers", "1954"),
47 | LoadBooksAction.Book("The Lord of the Rings - The Return of the King", "1955"),
48 | LoadBooksAction.Book("The Adventures of Tom Bombadil", "1962"),
49 | LoadBooksAction.Book("Tree and Leaf", "1964"),
50 | LoadBooksAction.Book("The Tolkien Reader", "1966"),
51 | LoadBooksAction.Book("The Road Goes Ever On", "1967"),
52 | LoadBooksAction.Book("Smith of Wootton Major", "1967")
53 | )
54 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/kotlin/de/halfbit/knot3/sample/books2/delegates/ClearButtonDelegate.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3.sample.books2.delegates
2 |
3 | import de.halfbit.knot3.CompositeKnot
4 | import de.halfbit.knot3.sample.books2.Delegate
5 | import de.halfbit.knot3.sample.books2.model.Event
6 | import de.halfbit.knot3.sample.books2.model.State
7 |
8 | class ClearButtonDelegate : Delegate {
9 | override fun CompositeKnot.register() {
10 | registerDelegate {
11 | changes {
12 | reduce { change ->
13 | when (this) {
14 | is State.Content -> State.Empty.only
15 | is State.Empty -> only
16 | else -> unexpected(change)
17 | }
18 | }
19 | }
20 | }
21 | }
22 |
23 | override fun CompositeKnot.onEvent(event: Event) =
24 | if (event == Event.Clear) {
25 | change.accept(Change.Clear)
26 | true
27 | } else false
28 |
29 | private sealed class Change {
30 | object Clear : Change()
31 | }
32 | }
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/kotlin/de/halfbit/knot3/sample/books2/delegates/LoadButtonDelegate.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3.sample.books2.delegates
2 |
3 | import de.halfbit.knot3.CompositeKnot
4 | import de.halfbit.knot3.sample.books2.Delegate
5 | import de.halfbit.knot3.sample.books2.actions.DefaultLoadBooksAction
6 | import de.halfbit.knot3.sample.books2.actions.LoadBooksAction
7 | import de.halfbit.knot3.sample.books2.model.Action
8 | import de.halfbit.knot3.sample.books2.model.Event
9 | import de.halfbit.knot3.sample.books2.model.State
10 | import de.halfbit.knot3.sample.books2.model.types.Book
11 |
12 | class LoadButtonDelegate(
13 | private val loadBooksAction: LoadBooksAction = DefaultLoadBooksAction()
14 | ) : Delegate {
15 |
16 | override fun CompositeKnot.register() {
17 | registerDelegate {
18 | changes {
19 | reduce {
20 | when (this) {
21 | State.Empty,
22 | is State.Content,
23 | is State.Error -> State.Loading + Load
24 | else -> only
25 | }
26 | }
27 | reduce { change ->
28 | when (this) {
29 | State.Loading -> State.Content(change.books).only
30 | else -> unexpected(change)
31 | }
32 | }
33 | reduce { change ->
34 | when (this) {
35 | State.Loading -> State.Error(change.message).only
36 | else -> unexpected(change)
37 | }
38 | }
39 | }
40 | actions {
41 | perform {
42 | switchMapSingle {
43 | loadBooksAction.perform()
44 | .map { it.toChange() }
45 | }
46 | }
47 | }
48 | }
49 | }
50 |
51 | override fun CompositeKnot.onEvent(event: Event) =
52 | if (event == Event.Load) {
53 | change.accept(Change.Load)
54 | true
55 | } else false
56 |
57 | private sealed class Change {
58 | object Load : Change() {
59 | data class Success(val books: List) : Change()
60 | data class Failure(val message: String) : Change()
61 | }
62 | }
63 |
64 | private object Load : Action
65 |
66 | private fun LoadBooksAction.Result.toChange() =
67 | when (this) {
68 | is LoadBooksAction.Result.Success ->
69 | Change.Load.Success(books.map { it.toBook() })
70 | is LoadBooksAction.Result.Failure.Network ->
71 | Change.Load.Failure("Network error. Check Internet connection and try again.")
72 | LoadBooksAction.Result.Failure.Generic ->
73 | Change.Load.Failure("Generic error, please try again.")
74 | }
75 |
76 | private fun LoadBooksAction.Book.toBook() = Book(title, year)
77 | }
78 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/kotlin/de/halfbit/knot3/sample/books2/model/Action.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3.sample.books2.model
2 |
3 | // Some delegates can serve other delegates by performing common actions like
4 | // show error or similar. Such common actions should be added as inner classes
5 | // to the Action interface.
6 | //
7 | // Actions, which are private to the delegates should be declared in the
8 | // delegates.
9 |
10 | interface Action
11 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/kotlin/de/halfbit/knot3/sample/books2/model/Event.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3.sample.books2.model
2 |
3 | sealed class Event {
4 | object Load : Event()
5 | object Clear : Event()
6 | }
7 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/kotlin/de/halfbit/knot3/sample/books2/model/State.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3.sample.books2.model
2 |
3 | import de.halfbit.knot3.sample.books2.model.types.Book
4 |
5 | sealed class State {
6 | object Empty : State()
7 | object Loading : State()
8 | data class Content(val books: List) : State()
9 | data class Error(val message: String) : State()
10 | }
11 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/kotlin/de/halfbit/knot3/sample/books2/model/types/Book.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3.sample.books2.model.types
2 |
3 | data class Book(val title: String, val year: String)
4 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/kotlin/de/halfbit/knot3/sample/common/ViewBinder.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3.sample.common
2 |
3 | import io.reactivex.rxjava3.disposables.CompositeDisposable
4 |
5 | interface ViewBinder {
6 | fun bind(disposable: CompositeDisposable)
7 | }
8 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
12 |
13 |
19 |
22 |
25 |
26 |
27 |
28 |
34 |
35 |
--------------------------------------------------------------------------------
/knot3-android-sample/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 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/res/layout/activity_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/res/layout/fragment_books.xml:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
14 |
15 |
19 |
20 |
24 |
25 |
29 |
30 |
34 |
35 |
36 |
37 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/res/layout/fragment_selector.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
18 |
19 |
30 |
31 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/res/layout/part_books_content.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
21 |
22 |
34 |
35 |
45 |
46 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/res/layout/part_books_empty.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
18 |
19 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/res/layout/part_books_error.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
19 |
20 |
29 |
30 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/res/layout/part_books_loading.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
17 |
18 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sergejsha/knot/371abc88a094960446c3727f30150b567ca41bc3/knot3-android-sample/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/res/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sergejsha/knot/371abc88a094960446c3727f30150b567ca41bc3/knot3-android-sample/src/main/res/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sergejsha/knot/371abc88a094960446c3727f30150b567ca41bc3/knot3-android-sample/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/res/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sergejsha/knot/371abc88a094960446c3727f30150b567ca41bc3/knot3-android-sample/src/main/res/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sergejsha/knot/371abc88a094960446c3727f30150b567ca41bc3/knot3-android-sample/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sergejsha/knot/371abc88a094960446c3727f30150b567ca41bc3/knot3-android-sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sergejsha/knot/371abc88a094960446c3727f30150b567ca41bc3/knot3-android-sample/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sergejsha/knot/371abc88a094960446c3727f30150b567ca41bc3/knot3-android-sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sergejsha/knot/371abc88a094960446c3727f30150b567ca41bc3/knot3-android-sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sergejsha/knot/371abc88a094960446c3727f30150b567ca41bc3/knot3-android-sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #008577
4 | #00574B
5 | #D81B60
6 |
7 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | knot sample
3 |
4 |
--------------------------------------------------------------------------------
/knot3-android-sample/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/knot3/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | kotlin("jvm")
3 | `maven-publish`
4 | id("org.gradle.signing")
5 | id("org.jetbrains.dokka")
6 | id("jacoco")
7 | }
8 |
9 | dependencies {
10 | implementation(kotlin(Deps.kotlinJdk))
11 | api(Deps.rxJava)
12 |
13 | testImplementation(Deps.junit)
14 | testImplementation(Deps.truth)
15 | testImplementation(Deps.mockito)
16 | testImplementation(Deps.mockitoKotlin)
17 | }
18 |
19 | tasks {
20 | jacocoTestReport {
21 | reports {
22 | xml.isEnabled = true
23 | with(html) {
24 | isEnabled = true
25 | destination = file("$buildDir/reports/jacoco/html")
26 | }
27 | }
28 | }
29 | check {
30 | dependsOn(jacocoTestReport)
31 | }
32 | compileKotlin {
33 | kotlinOptions.jvmTarget = Deps.Version.jvmTarget
34 | }
35 | compileTestKotlin {
36 | kotlinOptions.jvmTarget = Deps.Version.jvmTarget
37 | }
38 | }
39 |
40 | publishing {
41 |
42 | repositories {
43 | maven {
44 | name = "local"
45 | url = uri("$buildDir/repository")
46 | }
47 | maven {
48 | name = Pom.MavenCentral.name
49 | url = uri(Pom.MavenCentral.url)
50 | credentials {
51 | username = project.getNexusUser()
52 | password = project.getNexusPassword()
53 | }
54 | }
55 | }
56 |
57 | val dokka by tasks.getting(org.jetbrains.dokka.gradle.DokkaTask::class) {
58 | outputFormat = "javadoc"
59 | outputDirectory = "$buildDir/javadoc"
60 | noStdlibLink = false
61 | }
62 |
63 | val sourcesJar by tasks.creating(Jar::class) {
64 | archiveClassifier.set("sources")
65 | from(sourceSets["main"].allSource)
66 | }
67 |
68 | val javadocJar by tasks.creating(Jar::class) {
69 | archiveClassifier.set("javadoc")
70 | from(dokka)
71 | }
72 |
73 | publications {
74 | create("Knot", MavenPublication::class) {
75 | from(components["java"])
76 | artifact(sourcesJar)
77 | artifact(javadocJar)
78 | pom {
79 | name.set("Knot")
80 | description.set("Reactive state container library for Kotlin")
81 | url.set(Pom.url)
82 | licenses {
83 | license {
84 | name.set(Pom.License.name)
85 | url.set(Pom.License.url)
86 | }
87 | }
88 | developers {
89 | developer {
90 | id.set(Pom.Developer.id)
91 | name.set(Pom.Developer.name)
92 | email.set(Pom.Developer.email)
93 | }
94 | }
95 | scm {
96 | connection.set(Pom.Github.url)
97 | developerConnection.set(Pom.Github.cloneUrl)
98 | url.set(Pom.Github.url)
99 | }
100 | }
101 | }
102 | }
103 | }
104 |
105 | if (project.hasSigningKey()) {
106 | signing {
107 | sign(publishing.publications["Knot"])
108 | }
109 | }
--------------------------------------------------------------------------------
/knot3/src/main/kotlin/de/halfbit/knot3/CompositeKnot.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3
2 |
3 | import io.reactivex.rxjava3.core.Observable
4 | import io.reactivex.rxjava3.core.Scheduler
5 | import io.reactivex.rxjava3.disposables.CompositeDisposable
6 | import io.reactivex.rxjava3.disposables.Disposable
7 | import io.reactivex.rxjava3.functions.Consumer
8 | import io.reactivex.rxjava3.plugins.RxJavaPlugins
9 | import io.reactivex.rxjava3.subjects.BehaviorSubject
10 | import io.reactivex.rxjava3.subjects.PublishSubject
11 | import io.reactivex.rxjava3.subjects.Subject
12 | import java.util.concurrent.atomic.AtomicInteger
13 | import java.util.concurrent.atomic.AtomicReference
14 | import kotlin.reflect.KClass
15 |
16 | /**
17 | * If your [Knot] becomes big and you want to improve its readability and maintainability,
18 | * you may consider to decompose it. You start decomposition by grouping related
19 | * concerns into, in a certain sense, indecomposable pieces called `Delegate`.
20 | *
21 | * [Flowchart diagram](https://github.com/beworker/knot/raw/master/docs/diagrams/flowchart-composite-knot.png)
22 | *
23 | * Each `Delegate` is isolated from the other `Delegates`. It defines its own set of
24 | * `Changes`, `Actions` and `Reducers`. It's only the `State`, that is shared between
25 | * the `Delegates`. In that respect each `Delegate` can be seen as a separate [Knot] instance.
26 | *
27 | * Once all `Delegates` are registered at a `CompositeKnot`, the knot can be finally
28 | * composed using [compose] function and start operating.
29 | *
30 | * See [Composite ViewModel](https://www.halfbit.de/posts/composite-viewmodel/) for more details.
31 | */
32 | interface CompositeKnot : Knot {
33 |
34 | /** Registers a new `Delegate` in this composite knot. */
35 | @Deprecated(
36 | "Primes were renamed into Delegates. Use registerDelegate(block) instead.",
37 | ReplaceWith("registerDelegate(other)"),
38 | DeprecationLevel.WARNING
39 | )
40 | fun registerPrime(
41 | block: DelegateBuilder.() -> Unit
42 | )
43 |
44 | /** Registers a new `Delegate` in this composite knot. */
45 | fun registerDelegate(
46 | block: DelegateBuilder.() -> Unit
47 | )
48 |
49 | /** Finishes composition of `Delegates` and moves this knot into the operational mode. */
50 | fun compose()
51 | }
52 |
53 | internal class DefaultCompositeKnot(
54 | initialState: State,
55 | observeOn: Scheduler?,
56 | reduceOn: Scheduler?,
57 | stateInterceptors: MutableList>,
58 | changeInterceptors: MutableList>,
59 | actionInterceptors: MutableList>,
60 | actionSubject: Subject
61 | ) : CompositeKnot {
62 |
63 | private val coldEventSources = lazy { mutableListOf>() }
64 | private val composition = AtomicReference(
65 | Composition(
66 | initialState,
67 | observeOn,
68 | reduceOn,
69 | stateInterceptors,
70 | changeInterceptors,
71 | actionInterceptors,
72 | actionSubject
73 | )
74 | )
75 |
76 | private val stateSubject = BehaviorSubject.create()
77 | private val changeSubject = PublishSubject.create()
78 | private val disposables = CompositeDisposable()
79 |
80 | private val subscriberCount = AtomicInteger()
81 | private var coldEventsDisposable: Disposable? = null
82 | private var coldEventsObservable: Observable? = null
83 |
84 | override fun registerPrime(
85 | block: DelegateBuilder.() -> Unit
86 | ) = registerDelegate(block)
87 |
88 | @Suppress("UNCHECKED_CAST")
89 | override fun registerDelegate(
90 | block: DelegateBuilder.() -> Unit
91 | ) {
92 | composition.get()?.let {
93 | DelegateBuilder(
94 | it.reducers,
95 | it.eventSources,
96 | coldEventSources,
97 | it.actionTransformers,
98 | it.stateInterceptors,
99 | it.changeInterceptors,
100 | it.actionInterceptors
101 | ).also(block as DelegateBuilder.() -> Unit)
102 | } ?: error("Delegates cannot be registered after compose() was called")
103 | }
104 |
105 | override fun isDisposed(): Boolean = disposables.isDisposed
106 | override fun dispose() = disposables.dispose()
107 |
108 | override val state: Observable = stateSubject
109 | .doOnSubscribe { if (subscriberCount.getAndIncrement() == 0) maybeSubscribeColdEvents() }
110 | .doFinally { if (subscriberCount.decrementAndGet() == 0) maybeUnsubscribeColdEvents() }
111 |
112 | override val change: Consumer = Consumer {
113 | check(composition.get() == null) { "compose() must be called before emitting any change." }
114 | changeSubject.onNext(it)
115 | }
116 |
117 | @Synchronized
118 | private fun maybeSubscribeColdEvents() {
119 | if (coldEventSources.isInitialized() &&
120 | coldEventsDisposable == null &&
121 | subscriberCount.get() > 0
122 | ) {
123 | val coldEventsObservable =
124 | this.coldEventsObservable
125 | ?: coldEventSources
126 | .mergeIntoObservable()
127 | .also { this.coldEventsObservable = it }
128 |
129 | coldEventsDisposable =
130 | coldEventsObservable
131 | .subscribe(
132 | changeSubject::onNext,
133 | changeSubject::onError
134 | )
135 | }
136 | }
137 |
138 | @Synchronized
139 | private fun maybeUnsubscribeColdEvents() {
140 | coldEventsDisposable?.let {
141 | it.dispose()
142 | coldEventsDisposable = null
143 | }
144 | }
145 |
146 | override fun compose() {
147 | composition.getAndSet(null)?.let { composition ->
148 | disposables.add(
149 | Observable
150 | .merge(
151 | mutableListOf>().apply {
152 | this += changeSubject
153 |
154 | composition.actionSubject
155 | .intercept(composition.actionInterceptors)
156 | .bind(composition.actionTransformers) { this += it }
157 |
158 | composition.eventSources
159 | .map { source -> this += source() }
160 | }
161 | )
162 | .let { stream -> composition.reduceOn?.let { stream.observeOn(it) } ?: stream }
163 | .serialize()
164 | .intercept(composition.changeInterceptors)
165 | .scan(composition.initialState) { state, change ->
166 | val reducer = composition.reducers[change::class]
167 | ?: error("Cannot find reducer for $change")
168 | reducer(state, change).emitActions(composition.actionSubject)
169 | }
170 | .distinctUntilChanged { prev, curr -> prev === curr }
171 | .let { stream -> composition.observeOn?.let { stream.observeOn(it) } ?: stream }
172 | .intercept(composition.stateInterceptors)
173 | .subscribe(
174 | stateSubject::onNext,
175 | RxJavaPlugins::onError
176 | )
177 | )
178 | maybeSubscribeColdEvents()
179 | } ?: error("compose() must be called just once.")
180 | }
181 |
182 | private class Composition(
183 | val initialState: State,
184 | val observeOn: Scheduler?,
185 | val reduceOn: Scheduler?,
186 | val stateInterceptors: MutableList>,
187 | val changeInterceptors: MutableList>,
188 | val actionInterceptors: MutableList>,
189 | val actionSubject: Subject
190 | ) {
191 | val reducers = mutableMapOf, Reducer>()
192 | val actionTransformers = mutableListOf>()
193 | val eventSources = mutableListOf>()
194 | }
195 | }
196 |
--------------------------------------------------------------------------------
/knot3/src/main/kotlin/de/halfbit/knot3/Knot.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3
2 |
3 | import io.reactivex.rxjava3.core.Observable
4 | import io.reactivex.rxjava3.core.Scheduler
5 | import io.reactivex.rxjava3.disposables.CompositeDisposable
6 | import io.reactivex.rxjava3.disposables.Disposable
7 | import io.reactivex.rxjava3.functions.Consumer
8 | import io.reactivex.rxjava3.plugins.RxJavaPlugins
9 | import io.reactivex.rxjava3.subjects.PublishSubject
10 | import io.reactivex.rxjava3.subjects.Subject
11 |
12 |
13 | /**
14 | * Knot helps managing application state by reacting on events and performing asynchronous
15 | * actions in a structured way. There are five core concepts Knot defines: [State], [Change],
16 | * [Reducer], [Effect] and `Action`.
17 | *
18 | * [Flowchart diagram](https://github.com/beworker/knot/raw/master/docs/diagrams/flowchart-knot.png)
19 | *
20 | * [State] represents an immutable partial state of an Android application. It can be a state
21 | * of a screen or a state of an internal headless component, like repository.
22 | *
23 | * [Change] is an immutable data object with an optional payload intended for changing the `State`.
24 | * A `Change` can be produced from an external event or be a result of execution of an `Action`.
25 | *
26 | * `Action` is a synchronous or an asynchronous operation which, when completed, can emit a new `Change`.
27 | *
28 | * [Reducer] is a pure function that takes the previous `State` and a `Change` as arguments and returns
29 | * the new `State` and an optional `Action` wrapped by `Effect` class. `Reducer` in Knot is designer
30 | * to stays side-effects free because each side-effect can be turned into an `Action` and returned from
31 | * `Reducer` function together with a new `State`.
32 | *
33 | * [Effect] is a convenient wrapper class containing the new `State` and an optional `Action`. If
34 | * `Action` is present, Knot will perform it and provide resulting `Change` back to `Reducer`.
35 | *
36 | * Example below shows the Knot which is capable of loading data, handling success and failure
37 | * loading results and reloading data when an external "data changed" signal is received.
38 | * ```
39 | * val knot = knot {
40 | * state {
41 | * initial = State.Empty
42 | * }
43 | * changes {
44 | * reduce { change ->
45 | * when (change) {
46 | * is Change.Load -> State.Loading + Action.Load
47 | * is Change.Load.Success -> State.Content(data).only
48 | * is Change.Load.Failure -> State.Failed(error).only
49 | * }
50 | * }
51 | * }
52 | * actions {
53 | * perform { action ->
54 | * action
55 | * .switchMapSingle { api.load() }
56 | * .map { Change.Load.Success(it) }
57 | * .onErrorReturn { Change.Load.Failure(it) }
58 | * }
59 | * }
60 | * }
61 | * events {
62 | * transform {
63 | * dataChangeObserver.signal.map { Change.Load }
64 | * }
65 | * }
66 | * watch {
67 | * all { println(it) }
68 | * }
69 | * }
70 | *
71 | * knot.change.accept(Change.Load)
72 | * ```
73 | */
74 | interface Knot : Store {
75 |
76 | /** Change emitter used for delivering changes to this knot. */
77 | val change: Consumer
78 | }
79 |
80 | /** Store is a disposable container for a [State]. */
81 | interface Store : Disposable {
82 |
83 | /** Observable state. */
84 | val state: Observable
85 | }
86 |
87 | /** Convenience wrapper around [State] and optional [Action]. */
88 | sealed class Effect {
89 |
90 | /** Adds another action to [Effect]. */
91 | abstract operator fun plus(action: Action?): Effect
92 |
93 | data class WithAction(
94 | val state: State,
95 | val action: Action? = null
96 | ) : Effect() {
97 | override fun plus(action: Action?): Effect =
98 | when {
99 | action == null -> this
100 | this.action == null -> WithAction(state, action)
101 | else -> WithActions(state, listOf(this.action, action))
102 | }
103 | }
104 |
105 | data class WithActions(
106 | val state: State,
107 | val actions: List
108 | ) : Effect() {
109 | override fun plus(action: Action?): Effect =
110 | if (action == null) this
111 | else WithActions(state, actions + action)
112 | }
113 | }
114 |
115 | /** A function accepting the `State` and a `Change` and returning a new `State`. */
116 | typealias Reducer = State.(change: Change) -> Effect
117 |
118 | /** A function returning an [Observable] `Change`. */
119 | typealias EventSource = () -> Observable
120 |
121 | /** A function used for performing given `Action` and emitting resulting `Change` or *Changes*. */
122 | typealias ActionTransformer = (action: Observable) -> Observable
123 |
124 | /** A function used for performing given `Action` and emitting resulting `Change` or *Changes*. */
125 | typealias ActionTransformerWithReceiver = Observable.() -> Observable
126 |
127 | /** A function used for intercepting events of given type. */
128 | typealias Interceptor = (value: Observable) -> Observable
129 |
130 | /** A function used for consuming events of given type. */
131 | typealias Watcher = (value: Type) -> Unit
132 |
133 | internal class DefaultKnot(
134 | initialState: State,
135 | observeOn: Scheduler?,
136 | reduceOn: Scheduler?,
137 | reducer: Reducer,
138 | coldEventSources: Lazy>>,
139 | eventSources: List>,
140 | actionTransformers: List>,
141 | stateInterceptors: List>,
142 | changeInterceptors: List>,
143 | actionInterceptors: List>
144 | ) : Knot {
145 |
146 | private val changeSubject = PublishSubject.create()
147 | private val actionSubject = PublishSubject.create()
148 | private val disposables = CompositeDisposable()
149 |
150 | private var subscriberCount: Int = 0
151 | private var coldEventsObservable: Observable? = null
152 | private var coldEventsDisposable: Disposable? = null
153 |
154 | override fun isDisposed(): Boolean = disposables.isDisposed
155 | override fun dispose() = disposables.dispose()
156 |
157 | override val change: Consumer = Consumer { changeSubject.onNext(it) }
158 | override val state: Observable = Observable
159 | .merge(
160 | mutableListOf>().apply {
161 | this += changeSubject
162 |
163 | actionSubject
164 | .intercept(actionInterceptors)
165 | .bind(actionTransformers) { this += it }
166 |
167 | eventSources
168 | .map { transform -> this += transform() }
169 | }
170 | )
171 | .let { stream -> reduceOn?.let { stream.observeOn(it) } ?: stream }
172 | .serialize()
173 | .intercept(changeInterceptors)
174 | .scan(initialState) { state, change -> reducer(state, change).emitActions(actionSubject) }
175 | .distinctUntilChanged { prev, curr -> prev === curr }
176 | .intercept(stateInterceptors)
177 | .let { stream -> observeOn?.let { stream.observeOn(it) } ?: stream }
178 | .replay(1)
179 | .also {
180 | if (coldEventSources.isInitialized()) {
181 | coldEventsObservable = coldEventSources.mergeIntoObservable()
182 | }
183 | disposables.add(it.connect())
184 | }
185 | .doOnSubscribe(::doOnSubscribe)
186 | .doOnError(RxJavaPlugins::onError)
187 | .doFinally(::doFinally)
188 |
189 | @Synchronized
190 | private fun doOnSubscribe(disposable: Disposable) {
191 | if (subscriberCount++ == 0) {
192 | coldEventsObservable?.let {
193 | check(coldEventsDisposable == null)
194 | coldEventsDisposable = it.subscribe(
195 | changeSubject::onNext,
196 | changeSubject::onError
197 | )
198 | }
199 | }
200 | }
201 |
202 | @Synchronized
203 | private fun doFinally() {
204 | if (--subscriberCount == 0) {
205 | coldEventsDisposable?.let {
206 | it.dispose()
207 | coldEventsDisposable = null
208 | }
209 | }
210 | }
211 | }
212 |
213 | internal fun Effect.emitActions(
214 | actionSubject: Subject
215 | ): State = when (this) {
216 | is Effect.WithAction -> {
217 | action?.let { action -> actionSubject.onNext(action) }
218 | state
219 | }
220 | is Effect.WithActions -> {
221 | for (action in actions) actionSubject.onNext(action)
222 | state
223 | }
224 | }
225 |
226 | internal fun Observable.intercept(interceptors: List>): Observable =
227 | interceptors.fold(this) { state, intercept -> intercept(state) }
228 |
229 | internal fun Observable.bind(
230 | actionTransformers: List>,
231 | append: (observable: Observable) -> Unit
232 | ) {
233 | if (actionTransformers.isEmpty()) append(flatMap { Observable.empty() })
234 | else share().let { shared -> actionTransformers.map { transform -> append(transform(shared)) } }
235 | }
236 |
237 | internal fun Lazy>>.mergeIntoObservable(): Observable =
238 | Observable.merge(
239 | mutableListOf>().apply {
240 | value.map { source -> this += source() }
241 | }
242 | )
243 |
--------------------------------------------------------------------------------
/knot3/src/main/kotlin/de/halfbit/knot3/TestCompositeKnot.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3
2 |
3 | import io.reactivex.rxjava3.core.Observable
4 | import io.reactivex.rxjava3.functions.Consumer
5 | import io.reactivex.rxjava3.subjects.PublishSubject
6 | import java.util.concurrent.atomic.AtomicBoolean
7 |
8 | /**
9 | * Creates a [TestCompositeKnot]. This function should only be used in tests.
10 | * For creating a productive composite knot use [compositeKnot] function.
11 | */
12 | fun testCompositeKnot(
13 | block: CompositeKnotBuilder.() -> Unit
14 | ): TestCompositeKnot {
15 | val actionSubject = PublishSubject.create()
16 | return CompositeKnotBuilder()
17 | .also(block)
18 | .build(actionSubject)
19 | .let { compositeKnot ->
20 | DefaultTestCompositeKnot(
21 | compositeKnot,
22 | actionSubject
23 | )
24 | }
25 | }
26 |
27 | /**
28 | * `TestCompositeKnot` is used for testing knot delegates in isolation. Create test composition
29 | * knot, add a knot `Delegate` to it you want to test and start testing it. In addition to standard
30 | * `CompositeKnot` functionality `TestCompositeKnot` lets you observe and emit actions.
31 | */
32 | interface TestCompositeKnot : CompositeKnot {
33 |
34 | /** Actions observer to be used in tests. */
35 | val action: Observable
36 |
37 | /** Actions consumer to be used in tests. */
38 | val actionConsumer: Consumer
39 | }
40 |
41 | internal class DefaultTestCompositeKnot(
42 | private val compositeKnot: DefaultCompositeKnot,
43 | private val actionSubject: PublishSubject
44 | ) : TestCompositeKnot {
45 |
46 | private val composed = AtomicBoolean()
47 |
48 | override val action: Observable = actionSubject
49 | override val actionConsumer: Consumer = Consumer {
50 | check(composed.get()) { "compose() must be called before emitting actions" }
51 | actionSubject.onNext(it)
52 | }
53 |
54 | override fun registerPrime(
55 | block: DelegateBuilder.() -> Unit
56 | ) = compositeKnot.registerDelegate(block)
57 |
58 | override fun registerDelegate(
59 | block: DelegateBuilder.() -> Unit
60 | ) = compositeKnot.registerDelegate(block)
61 |
62 | override fun compose() {
63 | compositeKnot.compose()
64 | composed.set(true)
65 | }
66 |
67 | override val change: Consumer = compositeKnot.change
68 | override val state: Observable = compositeKnot.state
69 | override fun isDisposed(): Boolean = compositeKnot.isDisposed
70 | override fun dispose() = compositeKnot.dispose()
71 | }
72 |
--------------------------------------------------------------------------------
/knot3/src/main/kotlin/de/halfbit/knot3/partial/PartialReducer.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3.partial
2 |
3 | import de.halfbit.knot3.Effect
4 |
5 | /**
6 | * Partial reducer is useful when the same change (or its payload) needs to be
7 | * shared among multiple reducers. Such reducers should then implement the
8 | * `PartialReducer` interface and the main reducer, which initially receives the
9 | * change, should dispatch it (or its payload) to all partial reducers. Each
10 | * partial reducer should return a new state back, which will then be provided
11 | * to the next reducer in the list and so on until all partial reducers are
12 | * processed. The resulting state can be returned back from the main reducer to
13 | * knot.
14 | *
15 | * Use [dispatch] extension function for dispatching the payload to partial
16 | * reducers.
17 | */
18 | @Deprecated(
19 | "The interface is deprecated in favour of more open [de.halfbit.knot3.Partial].",
20 | ReplaceWith("Partial"),
21 | level = DeprecationLevel.WARNING
22 | )
23 | interface PartialReducer {
24 | fun reduce(state: State, payload: Payload): Effect
25 |
26 | /** Turns [State] into an [Effect] without [Action]. */
27 | val State.only: Effect get() = Effect.WithAction(this)
28 |
29 | /** Combines [State] and [Action] into [Effect]. */
30 | operator fun State.plus(action: Action?) = Effect.WithAction(this, action)
31 | }
32 |
33 | /**
34 | * This extension function implements the contract between the main reducer
35 | * and list of partial reducers as defined by [PartialReducer].
36 | *
37 | * ```kotlin
38 | * val reducers: List>
39 | *
40 | * knot {
41 | * state {
42 | * initial = State.Initial
43 | * }
44 | * changes {
45 | * reduce { change ->
46 | * reducers.dispatch(this, change.payload)
47 | * }
48 | * }
49 | * }
50 | * ```
51 | */
52 | @Deprecated(
53 | "Use Collection.dispatch(state, block) with new [de.halfbit.knot3.Partial].",
54 | ReplaceWith("dispatch(state, block)"),
55 | level = DeprecationLevel.WARNING
56 | )
57 | fun Collection>.dispatch(
58 | state: State, payload: Payload
59 | ): Effect {
60 | val actions = mutableListOf()
61 | val newState = fold(state) { partialState, reducer ->
62 | when (val effect = reducer.reduce(partialState, payload)) {
63 | is Effect.WithAction -> {
64 | effect.action?.let { actions += it }
65 | effect.state
66 | }
67 | is Effect.WithActions -> {
68 | actions += effect.actions
69 | effect.state
70 | }
71 | }
72 | }
73 | return if (actions.isEmpty()) Effect.WithAction(newState)
74 | else Effect.WithActions(newState, actions)
75 | }
76 |
--------------------------------------------------------------------------------
/knot3/src/main/kotlin/de/halfbit/knot3/partial/StateOnlyPartialReducer.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3.partial
2 |
3 | /**
4 | * This is a lightweight version of the [PartialReducer] to be used with
5 | * partial reducers which never emit actions.
6 | *
7 | * Use [dispatch] extension function for dispatching the payload to partial
8 | * reducers.
9 | */
10 | @Deprecated("Use free style implementation instead of this interface.")
11 | interface StateOnlyPartialReducer {
12 | fun reduce(state: State, payload: Payload): State
13 | }
14 |
15 | /**
16 | * This extension function implements the contract between the main reducer
17 | * and list of partial reducers as defined by [StateOnlyPartialReducer].
18 | *
19 | * ```kotlin
20 | * val reducers: List>
21 | *
22 | * knot {
23 | * state {
24 | * initial = State.Initial
25 | * }
26 | * changes {
27 | * reduce { change ->
28 | * reducers.dispatch(this, change.payload).only
29 | * }
30 | * }
31 | * }
32 | * ```
33 | */
34 | @Deprecated(
35 | "Use Collection.dispatchStateOnly(state, block) with new [de.halfbit.knot3.Partial].",
36 | ReplaceWith("dispatchStateOnly(state, block)"),
37 | level = DeprecationLevel.WARNING
38 | )
39 | fun Collection>.dispatch(
40 | state: State, payload: Payload
41 | ): State = fold(state) { partialState, reducer -> reducer.reduce(partialState, payload) }
42 |
--------------------------------------------------------------------------------
/knot3/src/test/kotlin/de/halfbit/knot3/CompositeKnotColdSourceTest.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3
2 |
3 | import com.google.common.truth.Truth.assertThat
4 | import de.halfbit.knot3.utils.RxPluginsException
5 | import io.reactivex.rxjava3.subjects.PublishSubject
6 | import org.junit.Rule
7 | import org.junit.Test
8 | import java.util.concurrent.atomic.AtomicInteger
9 |
10 | class CompositeKnotColdSourceTest {
11 |
12 | @Rule
13 | @JvmField
14 | var rxPluginsException: RxPluginsException = RxPluginsException.none()
15 |
16 | private data class State(val value: String)
17 | private sealed class Change {
18 | object A : Change()
19 | object B : Change()
20 | }
21 |
22 | private interface Action
23 |
24 | @Test
25 | fun `coldSource not subscribed if no state observers are registered`() {
26 |
27 | val changeASubscribed = AtomicInteger()
28 | val changeA = PublishSubject.create()
29 | .doOnSubscribe { changeASubscribed.incrementAndGet() }
30 | .doFinally { changeASubscribed.decrementAndGet() }
31 |
32 | val changeBSubscribed = AtomicInteger()
33 | val changeB = PublishSubject.create()
34 | .doOnSubscribe { changeBSubscribed.incrementAndGet() }
35 | .doFinally { changeBSubscribed.decrementAndGet() }
36 |
37 | val knot = compositeKnot {
38 | state { initial = State("empty") }
39 | }
40 |
41 | knot.registerDelegate {
42 | events {
43 | coldSource { changeA.map { Change.A } }
44 | }
45 | }
46 |
47 | knot.registerDelegate {
48 | events {
49 | coldSource { changeB.map { Change.B } }
50 | }
51 | }
52 |
53 | knot.compose()
54 |
55 | assertThat(changeASubscribed.get()).isEqualTo(0)
56 | assertThat(changeBSubscribed.get()).isEqualTo(0)
57 | }
58 |
59 | @Test
60 | fun `coldSource subscribed after first state observer is registered`() {
61 |
62 | val changeASubscribed = AtomicInteger()
63 | val changeA = PublishSubject.create()
64 | .doOnSubscribe { changeASubscribed.incrementAndGet() }
65 | .doFinally { changeASubscribed.decrementAndGet() }
66 |
67 | val changeBSubscribed = AtomicInteger()
68 | val changeB = PublishSubject.create()
69 | .doOnSubscribe { changeBSubscribed.incrementAndGet() }
70 | .doFinally { changeBSubscribed.decrementAndGet() }
71 |
72 | val knot = compositeKnot {
73 | state { initial = State("empty") }
74 | }
75 |
76 | knot.registerDelegate {
77 | events {
78 | coldSource { changeA.map { Change.A } }
79 | }
80 | }
81 |
82 | knot.registerDelegate {
83 | events {
84 | coldSource { changeB.map { Change.B } }
85 | }
86 | }
87 |
88 | knot.compose()
89 | knot.state.subscribe { }
90 |
91 | assertThat(changeASubscribed.get()).isEqualTo(1)
92 | assertThat(changeBSubscribed.get()).isEqualTo(1)
93 | }
94 |
95 | @Test
96 | fun `coldSource stays subscribed after second state observer is registered`() {
97 |
98 | val changeASubscribed = AtomicInteger()
99 | val changeA = PublishSubject.create()
100 | .doOnSubscribe { changeASubscribed.incrementAndGet() }
101 | .doFinally { changeASubscribed.decrementAndGet() }
102 |
103 | val changeBSubscribed = AtomicInteger()
104 | val changeB = PublishSubject.create()
105 | .doOnSubscribe { changeBSubscribed.incrementAndGet() }
106 | .doFinally { changeBSubscribed.decrementAndGet() }
107 |
108 | val knot = compositeKnot {
109 | state { initial = State("empty") }
110 | }
111 |
112 | knot.registerDelegate {
113 | events {
114 | coldSource { changeA.map { Change.A } }
115 | }
116 | }
117 |
118 | knot.registerDelegate {
119 | events {
120 | coldSource { changeB.map { Change.B } }
121 | }
122 | }
123 |
124 | knot.compose()
125 | knot.state.subscribe { }
126 | knot.state.subscribe { }
127 |
128 | assertThat(changeASubscribed.get()).isEqualTo(1)
129 | assertThat(changeBSubscribed.get()).isEqualTo(1)
130 | }
131 |
132 | @Test
133 | fun `coldSource stays subscribed after second state observer is unregistered`() {
134 |
135 | val changeASubscribed = AtomicInteger()
136 | val changeA = PublishSubject.create()
137 | .doOnSubscribe { changeASubscribed.incrementAndGet() }
138 | .doFinally { changeASubscribed.decrementAndGet() }
139 |
140 | val changeBSubscribed = AtomicInteger()
141 | val changeB = PublishSubject.create()
142 | .doOnSubscribe { changeBSubscribed.incrementAndGet() }
143 | .doFinally { changeBSubscribed.decrementAndGet() }
144 |
145 | val knot = compositeKnot {
146 | state { initial = State("empty") }
147 | }
148 |
149 | knot.registerDelegate {
150 | events {
151 | coldSource { changeA.map { Change.A } }
152 | }
153 | }
154 |
155 | knot.registerDelegate {
156 | events {
157 | coldSource { changeB.map { Change.B } }
158 | }
159 | }
160 |
161 | knot.compose()
162 | knot.state.subscribe { }
163 | val second = knot.state.subscribe { }
164 | second.dispose()
165 |
166 | assertThat(changeASubscribed.get()).isEqualTo(1)
167 | assertThat(changeBSubscribed.get()).isEqualTo(1)
168 | }
169 |
170 | @Test
171 | fun `coldSource gets unsubscribed after last state observer is unregistered`() {
172 |
173 | val changeASubscribed = AtomicInteger()
174 | val changeA = PublishSubject.create()
175 | .doOnSubscribe { changeASubscribed.incrementAndGet() }
176 | .doFinally { changeASubscribed.decrementAndGet() }
177 |
178 | val changeBSubscribed = AtomicInteger()
179 | val changeB = PublishSubject.create()
180 | .doOnSubscribe { changeBSubscribed.incrementAndGet() }
181 | .doFinally { changeBSubscribed.decrementAndGet() }
182 |
183 | val knot = compositeKnot {
184 | state { initial = State("empty") }
185 | }
186 |
187 | knot.registerDelegate {
188 | events {
189 | coldSource { changeA.map { Change.A } }
190 | }
191 | }
192 |
193 | knot.registerDelegate {
194 | events {
195 | coldSource { changeB.map { Change.B } }
196 | }
197 | }
198 |
199 | knot.compose()
200 |
201 | val first = knot.state.subscribe { }
202 | val second = knot.state.subscribe { }
203 | assertThat(changeASubscribed.get()).isEqualTo(1)
204 | assertThat(changeBSubscribed.get()).isEqualTo(1)
205 |
206 | first.dispose()
207 | assertThat(changeASubscribed.get()).isEqualTo(1)
208 | assertThat(changeBSubscribed.get()).isEqualTo(1)
209 |
210 | second.dispose()
211 | assertThat(changeASubscribed.get()).isEqualTo(0)
212 | assertThat(changeBSubscribed.get()).isEqualTo(0)
213 | }
214 |
215 | @Test
216 | fun `coldSource dispatches events when subscribed`() {
217 |
218 | val source = PublishSubject.create()
219 | val knot = compositeKnot {
220 | state { initial = State("empty") }
221 | }
222 |
223 | knot.registerDelegate {
224 | changes {
225 | reduce {
226 | State("event").only
227 | }
228 | }
229 | events {
230 | coldSource {
231 | source.map {
232 | Change.A
233 | }
234 | }
235 | }
236 | }
237 |
238 | knot.compose()
239 |
240 | val observer = knot.state.test()
241 | source.onNext("event")
242 | observer.assertValues(
243 | State("empty"),
244 | State("event")
245 | )
246 | }
247 |
248 | @Test
249 | fun `coldSource fails on error when subscribed`() {
250 |
251 | val givenError = IllegalStateException("Kaboom")
252 | val source = PublishSubject.create()
253 | val knot = compositeKnot {
254 | state { initial = State("empty") }
255 | }
256 |
257 | knot.registerDelegate {
258 | changes {
259 | reduce {
260 | only
261 | }
262 | }
263 | events {
264 | coldSource {
265 | source.map {
266 | throw givenError
267 | }
268 | }
269 | }
270 | }
271 |
272 | rxPluginsException.expect(givenError)
273 | knot.compose()
274 | knot.state.test()
275 | source.onNext("event")
276 | }
277 | }
278 |
--------------------------------------------------------------------------------
/knot3/src/test/kotlin/de/halfbit/knot3/CompositeKnotRequireStateTest.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3
2 |
3 | import de.halfbit.knot3.utils.RxPluginsException
4 | import org.junit.Rule
5 | import org.junit.Test
6 |
7 | class CompositeKnotRequireStateTest {
8 |
9 | @Rule
10 | @JvmField
11 | var rxPluginsException: RxPluginsException = RxPluginsException.none()
12 |
13 | private sealed class State {
14 | object Empty : State()
15 | object Loading : State()
16 | data class Content(val data: String) : State()
17 | }
18 |
19 | private sealed class Change {
20 | object Load : Change() {
21 | data class Success(val data: String) : Change()
22 | }
23 | }
24 |
25 | private sealed class Action {
26 | object Load : Action()
27 | }
28 |
29 | @Test
30 | fun `requireState lets known change through`() {
31 | val knot = createKnot(initialState = State.Empty)
32 | val states = knot.state.test()
33 | knot.change.accept(Change.Load)
34 | states.assertValues(
35 | State.Empty,
36 | State.Loading,
37 | State.Content("ok")
38 | )
39 | }
40 |
41 | @Test
42 | fun `requireState throws when unknown change received`() {
43 | rxPluginsException.expect(IllegalStateException::class)
44 |
45 | val knot = createKnot(initialState = State.Loading)
46 | knot.state.test()
47 | knot.change.accept(Change.Load)
48 | }
49 |
50 | private fun createKnot(initialState: State): CompositeKnot =
51 | compositeKnot {
52 | state { initial = initialState }
53 | }.apply {
54 | registerDelegate {
55 | changes {
56 | reduce { change ->
57 | requireState(change) {
58 | State.Loading + Action.Load
59 | }
60 | }
61 | reduce { change ->
62 | State.Content(change.data).only
63 | }
64 | }
65 | actions {
66 | perform {
67 | map { Change.Load.Success("ok") }
68 | }
69 | }
70 | }
71 | compose()
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/knot3/src/test/kotlin/de/halfbit/knot3/CompositeKnotTest.kt:
--------------------------------------------------------------------------------
1 | package de.halfbit.knot3
2 |
3 | import com.google.common.truth.Truth.assertThat
4 | import de.halfbit.knot3.utils.SchedulerTester
5 | import io.reactivex.rxjava3.schedulers.Schedulers
6 | import org.junit.Test
7 |
8 | class CompositeKnotTest {
9 |
10 | private object State
11 |
12 | @Test(expected = IllegalStateException::class)
13 | fun `DSL builder requires initial state`() {
14 | compositeKnot {}
15 | }
16 |
17 | @Test
18 | fun `DSL builder creates CompositeKnot`() {
19 | compositeKnot {
20 | state {
21 | initial = State
22 | }
23 | }
24 | }
25 |
26 | @Test
27 | fun `When not composed, knot doesn't dispatch initial state`() {
28 | val knot = compositeKnot {
29 | state {
30 | initial = State
31 | }
32 | }
33 | val observer = knot.state.test()
34 | observer.assertNoValues()
35 | }
36 |
37 | @Test
38 | fun `When not composed, knot can be composed`() {
39 | val knot = compositeKnot {
40 | state {
41 | initial = State
42 | }
43 | }
44 | knot.compose()
45 | }
46 |
47 | @Test(expected = IllegalStateException::class)
48 | fun `When composed, knot fails to accept new composition`() {
49 | val knot = compositeKnot {
50 | state {
51 | initial = State
52 | }
53 | }
54 | knot.compose()
55 | knot.compose()
56 | }
57 |
58 | @Test(expected = IllegalStateException::class)
59 | fun `When composed, knot fails to access new delegates`() {
60 | val knot = compositeKnot {
61 | state {
62 | initial = State
63 | }
64 | }
65 | knot.compose()
66 | knot.registerDelegate