├── .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 | [![Maven Central](http://img.shields.io/maven-central/v/de.halfbit/knot.svg)](https://central.sonatype.com/artifact/de.halfbit/knot3) 2 | ![maintenance-status](https://img.shields.io/badge/maintenance-passively--maintained-yellowgreen.svg) 3 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](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 [![Maven Central](http://img.shields.io/maven-central/v/de.halfbit/knot3.svg)](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 [![Maven Central](http://img.shields.io/maven-central/v/de.halfbit/knot.svg)](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 |