├── .gitignore ├── LICENSE ├── README.md ├── build.gradle.kts ├── buildSrc ├── build.gradle.kts └── src │ └── main │ └── kotlin │ ├── Version.kt │ └── shared-android.gradle.kts ├── composable-architecture-android ├── .gitignore ├── build.gradle.kts └── src │ └── main │ ├── AndroidManifest.xml │ └── kotlin │ └── composablearchitecture │ └── android │ └── ScopedViewModel.kt ├── composable-architecture-test ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── composablearchitecture │ └── test │ ├── TestExecutorService.kt │ └── TestStore.kt ├── composable-architecture ├── build.gradle.kts └── src │ ├── main │ └── kotlin │ │ └── composablearchitecture │ │ ├── ArrowOptics+Helpers.kt │ │ ├── Effect+Cancellation.kt │ │ ├── Effect.kt │ │ ├── Helpers.kt │ │ ├── Reducer+Debug.kt │ │ ├── Reducer.kt │ │ └── Store.kt │ └── test │ └── kotlin │ └── composablearchitecture │ └── sandbox │ ├── Sandbox.kt │ └── optional │ └── Sandbox.kt ├── examples ├── case-studies │ ├── .gitignore │ ├── build.gradle.kts │ ├── proguard-rules.pro │ └── src │ │ ├── androidTest │ │ └── kotlin │ │ │ └── composablearchitecture │ │ │ └── example │ │ │ └── casestudies │ │ │ └── ExampleInstrumentedTest.kt │ │ └── main │ │ ├── AndroidManifest.xml │ │ ├── kotlin │ │ └── composablearchitecture │ │ │ └── example │ │ │ └── casestudies │ │ │ ├── AnimationActivity.kt │ │ │ └── CaseStudiesApp.kt │ │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ ├── circle.xml │ │ └── ic_launcher_background.xml │ │ ├── layout │ │ └── main_activity.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 ├── search │ ├── .gitignore │ ├── build.gradle.kts │ ├── proguard-rules.pro │ └── src │ │ ├── androidTest │ │ └── kotlin │ │ │ └── composablearchitecture │ │ │ └── example │ │ │ └── search │ │ │ └── ExampleInstrumentedTest.kt │ │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── kotlin │ │ │ └── composablearchitecture │ │ │ │ └── example │ │ │ │ └── search │ │ │ │ ├── ComposeSearchActivity.kt │ │ │ │ ├── LocalDateAdapter.kt │ │ │ │ ├── Search.kt │ │ │ │ ├── SearchActivity.kt │ │ │ │ ├── SearchAdapter.kt │ │ │ │ ├── SearchApp.kt │ │ │ │ └── WeatherClient.kt │ │ └── res │ │ │ ├── drawable-v24 │ │ │ └── ic_launcher_foreground.xml │ │ │ ├── drawable │ │ │ ├── ic_baseline_add_24.xml │ │ │ ├── ic_baseline_search_24.xml │ │ │ └── ic_launcher_background.xml │ │ │ ├── layout │ │ │ ├── location_item.xml │ │ │ └── search_activity.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 │ │ └── test │ │ └── kotlin │ │ └── composablearchitecture │ │ └── example │ │ └── search │ │ ├── LiveStoreTests.kt │ │ ├── Mocks.kt │ │ └── SearchTests.kt ├── tic-tac-toe │ ├── .gitignore │ ├── build.gradle.kts │ ├── proguard-rules.pro │ └── src │ │ ├── androidTest │ │ └── kotlin │ │ │ └── composablearchitecture │ │ │ └── example │ │ │ └── tictactoe │ │ │ └── ExampleInstrumentedTest.kt │ │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── kotlin │ │ │ └── composablearchitecture │ │ │ │ └── example │ │ │ │ └── tictactoe │ │ │ │ ├── Array+Helpers.kt │ │ │ │ ├── TicTacToe.kt │ │ │ │ ├── TicTacToeActivity.kt │ │ │ │ └── TicTacToeApp.kt │ │ └── res │ │ │ ├── drawable-v24 │ │ │ └── ic_launcher_foreground.xml │ │ │ ├── drawable │ │ │ ├── ic_baseline_add_24.xml │ │ │ └── ic_launcher_background.xml │ │ │ ├── layout │ │ │ └── tictactoe_activity.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 │ │ └── test │ │ └── kotlin │ │ └── composablearchitecture │ │ └── example │ │ └── tictactoe │ │ ├── ExampleUnitTest.kt │ │ └── TicTacToeTest.kt └── todos │ ├── .gitignore │ ├── build.gradle.kts │ ├── proguard-rules.pro │ └── src │ ├── androidTest │ └── kotlin │ │ └── composablearchitecture │ │ └── example │ │ └── todos │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── kotlin │ │ └── composablearchitecture │ │ │ └── example │ │ │ └── todos │ │ │ ├── TodoAdapter.kt │ │ │ ├── Todos.kt │ │ │ ├── TodosActivity.kt │ │ │ └── TodosApp.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ ├── ic_baseline_add_24.xml │ │ └── ic_launcher_background.xml │ │ ├── layout │ │ ├── todo_item.xml │ │ └── todos_activity.xml │ │ ├── menu │ │ └── todos_menu.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 │ └── test │ └── kotlin │ └── composablearchitecture │ └── example │ └── todos │ ├── TodosSandbox.kt │ └── TodosTest.kt ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | *.apk 3 | *.aar 4 | *.ap_ 5 | *.aab 6 | 7 | # Files for the ART/Dalvik VM 8 | *.dex 9 | 10 | # Java class files 11 | *.class 12 | 13 | # Generated files 14 | bin/ 15 | gen/ 16 | out/ 17 | # Uncomment the following line in case you need and you don't have the release build type files in your app 18 | # release/ 19 | 20 | # Gradle files 21 | .gradle/ 22 | build/ 23 | 24 | # Local configuration file (sdk path, etc) 25 | local.properties 26 | 27 | # Proguard folder generated by Eclipse 28 | proguard/ 29 | 30 | # Log Files 31 | *.log 32 | 33 | # Android Studio Navigation editor temp files 34 | .navigation/ 35 | 36 | # Android Studio captures folder 37 | captures/ 38 | 39 | # IntelliJ 40 | *.iml 41 | .idea/ 42 | 43 | # Keystore files 44 | # Uncomment the following lines if you do not want to check your keystore files in. 45 | #*.jks 46 | #*.keystore 47 | 48 | # External native build folder generated in Android Studio 2.2 and later 49 | .externalNativeBuild 50 | .cxx/ 51 | 52 | # Google Services (e.g. APIs or Firebase) 53 | # google-services.json 54 | 55 | # Freeline 56 | freeline.py 57 | freeline/ 58 | freeline_project_description.json 59 | 60 | # fastlane 61 | fastlane/report.xml 62 | fastlane/Preview.html 63 | fastlane/screenshots 64 | fastlane/test_output 65 | fastlane/readme.md 66 | 67 | # Version control 68 | vcs.xml 69 | 70 | # lint 71 | lint/intermediates/ 72 | lint/generated/ 73 | lint/outputs/ 74 | lint/tmp/ 75 | # lint/reports/ 76 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Makery, Kft. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kotlin Composable Architecture 2 | 3 | [![Kotlin](https://img.shields.io/badge/kotlin-1.4.21-orange)](https://kotlinlang.org/docs/tutorials/getting-started.html) 4 | [![@wearemakery](https://img.shields.io/badge/contact-@wearemakery-blue)](https://twitter.com/wearemakery) 5 | 6 | The Kotlin Composable Architecture is a companion library for the amazing [Swift Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture) created by [Point-Free](https://www.pointfree.co/). This implementation tries to mimic the original version's patterns and techniques, but because Swift and Kotlin have some fundamental differences, there are a few alternative design decisions. Eventually, we are aiming to provide the same ergonomics as the Swift implementation and full feature parity. 7 | 8 | ⚠️ **Please note that this repository is a work in progress; there is no stable release available.** 9 | 10 | ## Getting started 11 | 12 | Until there is no stable release available, the easiest way to integrate the library into your project is to use [Gradle's `includeBuild()` feature](https://publicobject.com/2021/03/11/includebuild/). 13 | 14 | ```kotlin 15 | // in build.gradle.kts 16 | implementation("composable-architecture:composable-architecture:0.1.0") 17 | ``` 18 | 19 | ```kotlin 20 | // in settings.gradle.kts 21 | includeBuild("") { 22 | dependencySubstitution { 23 | substitute(module("composable-architecture:composable-architecture")) 24 | .with(project(":composable-architecture")) 25 | } 26 | } 27 | ``` 28 | 29 | ## What is the Composable Architecture? 30 | 31 | This library provides a few core tools that can be used to build applications of varying purpose and complexity. It provides compelling stories that you can follow to solve many problems you encounter day-to-day when building applications, such as: 32 | 33 | * **State management** 34 |
How to manage the state of your application using simple value types, and share state across many screens so that mutations in one screen can be immediately observed in another screen. 35 | 36 | * **Composition** 37 |
How to break down large features into smaller components that can be extracted to their own, isolated modules and be easily glued back together to form the feature. 38 | 39 | * **Side effects** 40 |
How to let certain parts of the application talk to the outside world in the most testable and understandable way possible. 41 | 42 | * **Testing** 43 |
How to not only test a feature built in the architecture, but also write integration tests for features that have been composed of many parts, and write end-to-end tests to understand how side effects influence your application. This allows you to make strong guarantees that your business logic is running in the way you expect. 44 | 45 | * **Ergonomics** 46 |
How to accomplish all of the above in a simple API with as few concepts and moving parts as possible. 47 | 48 | ## Alternative approaches compared to Swift 49 | 50 | ### Lack of value types 51 | 52 | For now, the JVM doesn't have the concept of value types (this might change in the future with [Project Valhalla](https://openjdk.java.net/projects/valhalla/)). Thus, we cannot provide the reducer a mutable state safely, so it is required to return new copies of the state in case of any mutation. Kotlin's `data class` feature comes handy, as a `.copy()` methods get generated with named arguments for all properties. 53 | 54 | ### Less powerful enums 55 | 56 | In Kotlin, enums cannot function as algebraic data types. We get to define a single type wrapped inside an enum, but each case cannot have an individual associated type. Instead, we can model actions with `sealed class` hierarchies. 57 | 58 | ### Lack of KeyPaths and CasePaths 59 | 60 | The Swift compiler has a powerful feature: it generates `KeyPath`s for each struct in our application. Point-Free has implemented a companion library called [swift-case-paths](https://github.com/pointfreeco/swift-case-paths), which provides the same ergonomics for enums. The Swift Composable Architecture uses these two tools to abstract over getters and setters for state and action. In Kotlin, we don't have similar tools, so we rely on code generation through the [Arrow Meta](https://github.com/arrow-kt/arrow) library. Arrow has a module called Optics, which can be utilized to define `Lens`es and `Prism`s to substitute `KeyPath`s and `CasePath`s. 61 | 62 | ### Combine vs Coroutines 63 | 64 | The Swift Composable Architecture is powered by Combine, which comes bundled with iOS SDK 13. The Kotlin Composable Architecture is relying on the Kotlinx Coroutines library. Stores are powered by `MutableStateFlow`, and effects are wrappers for coroutine `Flow`s. 65 | 66 | ## More info 67 | 68 | For more information please visit the [Swift Composable Architecture repository](https://github.com/pointfreeco/swift-composable-architecture). 69 | 70 | ## License 71 | 72 | This library is released under the MIT license. See [LICENSE](LICENSE) for details. 73 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | 3 | buildscript { 4 | repositories { 5 | google() 6 | mavenCentral() 7 | } 8 | dependencies { 9 | classpath("com.android.tools.build:gradle:$androidToolsBuildVersion") 10 | classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") 11 | } 12 | } 13 | 14 | allprojects { 15 | repositories { 16 | google() 17 | mavenCentral() 18 | } 19 | 20 | tasks.withType().all { 21 | kotlinOptions.freeCompilerArgs += "-Xopt-in=kotlin.ExperimentalStdlibApi" 22 | kotlinOptions.freeCompilerArgs += "-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi" 23 | kotlinOptions.freeCompilerArgs += "-Xopt-in=kotlinx.coroutines.FlowPreview" 24 | 25 | kotlinOptions.jvmTarget = "1.8" 26 | } 27 | 28 | project.version = "0.1.0" 29 | } 30 | 31 | tasks.register("clean") { 32 | delete(rootProject.buildDir) 33 | } 34 | -------------------------------------------------------------------------------- /buildSrc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("HasPlatformType") 2 | 3 | import java.util.Properties 4 | 5 | plugins { 6 | `kotlin-dsl` 7 | `kotlin-dsl-precompiled-script-plugins` 8 | } 9 | 10 | repositories { 11 | google() 12 | jcenter() 13 | } 14 | 15 | private val properties = Properties() 16 | .apply { File(rootDir.parentFile, "gradle.properties").inputStream().use { load(it) } } 17 | 18 | val androidToolsBuildVersion = properties.getProperty("androidToolsBuildVersion") 19 | val kotlinVersion = properties.getProperty("kotlinVersion") 20 | 21 | dependencies { 22 | implementation("com.android.tools.build:gradle:$androidToolsBuildVersion") 23 | implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") 24 | } 25 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/Version.kt: -------------------------------------------------------------------------------- 1 | import org.gradle.api.Project 2 | import java.io.File 3 | import java.util.Properties 4 | 5 | // Versions 6 | val Project.androidToolsBuildVersion: String get() = props.getProperty("androidToolsBuildVersion") 7 | val Project.androidxActivityVersion: String get() = props.getProperty("androidxActivityVersion") 8 | val Project.androidxAppcompatVersion: String get() = props.getProperty("androidxAppcompatVersion") 9 | val Project.androidxConstraintLayoutVersion: String get() = props.getProperty("androidxConstraintLayoutVersion") 10 | val Project.androidxCoreVersion: String get() = props.getProperty("androidxCoreVersion") 11 | val Project.androidxDynamicAnimationVersion: String get() = props.getProperty("androidxDynamicAnimationVersion") 12 | val Project.androidxEspressoVersion: String get() = props.getProperty("androidxEspressoVersion") 13 | val Project.androidxJunitVersion: String get() = props.getProperty("androidxJunitVersion") 14 | val Project.androidxLifecycleVersion: String get() = props.getProperty("androidxLifecycleVersion") 15 | val Project.androidxRecyclerviewVersion: String get() = props.getProperty("androidxRecyclerviewVersion") 16 | val Project.arrowVersion: String get() = props.getProperty("arrowVersion") 17 | val Project.coroutinesVersion: String get() = props.getProperty("coroutinesVersion") 18 | val Project.junitVersion: String get() = props.getProperty("junitVersion") 19 | val Project.kotlinComposeVersion: String get() = props.getProperty("kotlinComposeVersion") 20 | val Project.kotlinVersion: String get() = props.getProperty("kotlinVersion") 21 | val Project.moshiVersion: String get() = props.getProperty("moshiVersion") 22 | val Project.okhttpVersion: String get() = props.getProperty("okhttpVersion") 23 | val Project.retrofitVersion: String get() = props.getProperty("retrofitVersion") 24 | 25 | // Android 26 | val Project.androidCompileSdkVersion: Int get() = props.getProperty("androidCompileSdkVersion").toInt() 27 | val Project.androidMinSdkVersion: Int get() = props.getProperty("androidMinSdkVersion").toInt() 28 | val Project.androidTargetSdkVersion: Int get() = props.getProperty("androidTargetSdkVersion").toInt() 29 | 30 | private val Project.props: Properties 31 | get() = Properties().apply { File(rootDir, "gradle.properties").inputStream().use { load(it) } } 32 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/shared-android.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.application") 3 | id("kotlin-android") 4 | id("kotlin-kapt") 5 | } 6 | 7 | @Suppress("UnstableApiUsage") 8 | android { 9 | compileSdk = androidCompileSdkVersion 10 | 11 | defaultConfig { 12 | minSdk = androidMinSdkVersion 13 | targetSdk = androidTargetSdkVersion 14 | versionCode = 1 15 | versionName = "0.1.0" 16 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 17 | } 18 | 19 | buildFeatures { 20 | dataBinding = true 21 | viewBinding = true 22 | } 23 | 24 | buildTypes { 25 | getByName("release") { 26 | isMinifyEnabled = false 27 | proguardFiles( 28 | getDefaultProguardFile("proguard-android-optimize.txt"), 29 | "proguard-rules.pro" 30 | ) 31 | } 32 | } 33 | 34 | @Suppress("UnstableApiUsage") 35 | compileOptions { 36 | kotlinOptions { 37 | jvmTarget = "1.8" 38 | } 39 | } 40 | 41 | sourceSets["androidTest"].java.srcDir("src/androidTest/kotlin") 42 | sourceSets["main"].java.srcDir("src/main/kotlin") 43 | sourceSets["test"].java.srcDir("src/test/kotlin") 44 | } 45 | 46 | dependencies { 47 | androidTestImplementation("androidx.test.espresso:espresso-core:$androidxEspressoVersion") 48 | androidTestImplementation("androidx.test.ext:junit:$androidxJunitVersion") 49 | implementation("androidx.activity:activity-ktx:$androidxActivityVersion") 50 | implementation("androidx.appcompat:appcompat:$androidxAppcompatVersion") 51 | implementation("androidx.constraintlayout:constraintlayout:$androidxConstraintLayoutVersion") 52 | implementation("androidx.core:core-ktx:$androidxCoreVersion") 53 | implementation("androidx.lifecycle:lifecycle-runtime-ktx:$androidxLifecycleVersion") 54 | implementation("androidx.recyclerview:recyclerview:$androidxRecyclerviewVersion") 55 | implementation("io.arrow-kt:arrow-optics:$arrowVersion") 56 | implementation(project(":composable-architecture")) 57 | implementation(project(":composable-architecture-android")) 58 | kapt("io.arrow-kt:arrow-meta:$arrowVersion") 59 | testImplementation("junit:junit:$junitVersion") 60 | testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion") 61 | testImplementation(project(":composable-architecture-test")) 62 | } 63 | -------------------------------------------------------------------------------- /composable-architecture-android/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /composable-architecture-android/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.library") 3 | id("kotlin-android") 4 | } 5 | 6 | android { 7 | compileSdkVersion(androidCompileSdkVersion) 8 | sourceSets["main"].java.srcDir("src/main/kotlin") 9 | } 10 | 11 | dependencies { 12 | implementation("androidx.lifecycle:lifecycle-livedata-ktx:$androidxLifecycleVersion") 13 | implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$androidxLifecycleVersion") 14 | implementation("io.arrow-kt:arrow-optics:$arrowVersion") 15 | implementation(project(":composable-architecture")) 16 | } 17 | -------------------------------------------------------------------------------- /composable-architecture-android/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /composable-architecture-android/src/main/kotlin/composablearchitecture/android/ScopedViewModel.kt: -------------------------------------------------------------------------------- 1 | package composablearchitecture.android 2 | 3 | import androidx.lifecycle.MutableLiveData 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import arrow.optics.Lens 7 | import arrow.optics.Prism 8 | import composablearchitecture.Store 9 | import kotlinx.coroutines.Job 10 | import kotlinx.coroutines.flow.collect 11 | import kotlinx.coroutines.launch 12 | 13 | open class ScopedViewModel : ViewModel() { 14 | 15 | val state: MutableLiveData = MutableLiveData() 16 | 17 | protected lateinit var store: Store 18 | 19 | fun launch( 20 | globalStore: Store, 21 | lens: Lens, 22 | prism: Prism 23 | ): Job { 24 | store = globalStore.scope(lens, prism, viewModelScope) 25 | return viewModelScope.launch { 26 | store.states.collect { state.value = it } 27 | } 28 | } 29 | 30 | fun launch(globalStore: Store): Job { 31 | store = globalStore 32 | return viewModelScope.launch { 33 | store.states.collect { state.value = it } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /composable-architecture-test/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("kotlin") 3 | } 4 | 5 | dependencies { 6 | implementation("io.arrow-kt:arrow-optics:$arrowVersion") 7 | implementation("org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion") 8 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion") 9 | implementation(project(":composable-architecture")) 10 | } 11 | -------------------------------------------------------------------------------- /composable-architecture-test/src/main/kotlin/composablearchitecture/test/TestExecutorService.kt: -------------------------------------------------------------------------------- 1 | package composablearchitecture.test 2 | 3 | import java.util.concurrent.AbstractExecutorService 4 | import java.util.concurrent.TimeUnit 5 | 6 | class TestExecutorService : AbstractExecutorService() { 7 | 8 | @Volatile 9 | private var terminated: Boolean = false 10 | 11 | override fun isTerminated(): Boolean = terminated 12 | 13 | override fun execute(command: Runnable) { 14 | command.run() 15 | } 16 | 17 | override fun shutdown() { 18 | terminated = true 19 | } 20 | 21 | override fun shutdownNow(): MutableList = mutableListOf() 22 | 23 | override fun isShutdown(): Boolean = terminated 24 | 25 | override fun awaitTermination(timeout: Long, timeUnit: TimeUnit): Boolean { 26 | shutdown() 27 | return terminated 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /composable-architecture-test/src/main/kotlin/composablearchitecture/test/TestStore.kt: -------------------------------------------------------------------------------- 1 | package composablearchitecture.test 2 | 3 | import arrow.optics.Lens 4 | import arrow.optics.Prism 5 | import composablearchitecture.Reducer 6 | import kotlinx.coroutines.CancellationException 7 | import kotlinx.coroutines.GlobalScope 8 | import kotlinx.coroutines.launch 9 | import kotlinx.coroutines.test.TestCoroutineDispatcher 10 | 11 | internal sealed class Step { 12 | class Send( 13 | val action: Action, 14 | val block: (State) -> State 15 | ) : Step() 16 | 17 | class Receive( 18 | val action: Action, 19 | val block: (State) -> State 20 | ) : Step() 21 | 22 | class Environment( 23 | val block: (Environment) -> Unit 24 | ) : Step() 25 | 26 | class Do( 27 | val block: () -> Unit 28 | ) : Step() 29 | } 30 | 31 | class AssertionBuilder(private val currentState: () -> State) { 32 | 33 | internal val steps: MutableList> = mutableListOf() 34 | 35 | fun send(action: Action, block: (State) -> State) = steps.add(Step.Send(action, block)) 36 | 37 | fun send(action: Action) = steps.add(Step.Send(action, { currentState() })) 38 | 39 | fun receive(action: Action, block: (State) -> State) = steps.add(Step.Receive(action, block)) 40 | 41 | fun receive(action: Action) = steps.add(Step.Receive(action, { currentState() })) 42 | 43 | fun environment(block: (Environment) -> Unit) = steps.add(Step.Environment(block)) 44 | 45 | fun doBlock(block: () -> Unit) = steps.add(Step.Do(block)) 46 | } 47 | 48 | class TestStore, LocalAction, Environment> 49 | private constructor( 50 | private var state: State, 51 | private val reducer: Reducer, 52 | private val environment: Environment, 53 | private val toLocalState: Lens, 54 | private val fromLocalAction: Prism, 55 | private val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher() 56 | ) { 57 | 58 | companion object { 59 | operator fun , Environment> invoke( 60 | state: State, 61 | reducer: Reducer, 62 | environment: Environment, 63 | testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher() 64 | ) = 65 | TestStore( 66 | state, 67 | reducer, 68 | environment, 69 | Lens.id(), 70 | Prism.id(), 71 | testDispatcher 72 | ) 73 | } 74 | 75 | fun scope( 76 | toLocalState: Lens, 77 | fromLocalAction: Prism 78 | ): TestStore = 79 | TestStore( 80 | state, 81 | reducer, 82 | environment, 83 | toLocalState, 84 | fromLocalAction, 85 | testDispatcher 86 | ) 87 | 88 | fun assert(block: AssertionBuilder.() -> Unit) { 89 | val assertion = AssertionBuilder { 90 | toLocalState.get(state) 91 | } 92 | assertion.block() 93 | 94 | val receivedActions: MutableList = mutableListOf() 95 | 96 | fun runReducer(action: Action) { 97 | val (newState, effect) = reducer.run(state, action, environment) 98 | state = newState 99 | 100 | GlobalScope.launch(testDispatcher) { 101 | try { 102 | val actions = effect.sink() 103 | receivedActions.addAll(actions) 104 | } catch (ex: CancellationException) { 105 | // ignore 106 | } 107 | } 108 | } 109 | 110 | assertion.steps.forEach { step -> 111 | var expectedState = toLocalState.get(state) 112 | 113 | when (step) { 114 | is Step.Send -> { 115 | require(receivedActions.isEmpty()) { "Must handle all actions" } 116 | runReducer(fromLocalAction.reverseGet(step.action)) 117 | expectedState = step.block(expectedState) 118 | } 119 | is Step.Receive -> { 120 | require(receivedActions.isNotEmpty()) { "Expected to receive an action, but received none" } 121 | val receivedAction = receivedActions.removeFirst() 122 | require(step.action == receivedAction) { "Actual and expected actions do not match" } 123 | runReducer(fromLocalAction.reverseGet(step.action)) 124 | expectedState = step.block(expectedState) 125 | } 126 | is Step.Environment -> { 127 | require(receivedActions.isEmpty()) { "Must handle all received actions before performing this work" } 128 | step.block(environment) 129 | } 130 | is Step.Do -> step.block() 131 | } 132 | 133 | val actualState = toLocalState.get(state) 134 | require(actualState == expectedState) { 135 | println(actualState) 136 | println("---vs---") 137 | println(expectedState) 138 | "Actual and expected states do not match" 139 | } 140 | } 141 | 142 | require(receivedActions.isEmpty()) { "Must handle all actions" } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /composable-architecture/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("kotlin") 3 | id("kotlin-kapt") 4 | } 5 | 6 | dependencies { 7 | implementation("io.arrow-kt:arrow-optics:$arrowVersion") 8 | implementation("org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion") 9 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion") 10 | kaptTest("io.arrow-kt:arrow-meta:$arrowVersion") 11 | testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion") 12 | testImplementation(project(":composable-architecture-test")) 13 | } 14 | -------------------------------------------------------------------------------- /composable-architecture/src/main/kotlin/composablearchitecture/ArrowOptics+Helpers.kt: -------------------------------------------------------------------------------- 1 | package composablearchitecture 2 | 3 | import arrow.core.Either 4 | import arrow.core.left 5 | import arrow.core.right 6 | import arrow.optics.Lens 7 | import arrow.optics.Optional 8 | import arrow.optics.dsl.index 9 | import arrow.optics.typeclasses.Index 10 | 11 | private fun listIndex(): Index, Int, A> = object : Index, Int, A> { 12 | override fun index(i: Int): Optional, A> = object : Optional, A> { 13 | override fun getOrModify(source: List): Either, A> = 14 | source.getOrNull(i)?.right() ?: source.left() 15 | 16 | override fun set(source: List, focus: A): List = 17 | source.update(i, focus) 18 | } 19 | } 20 | 21 | @Suppress("UNCHECKED_CAST") 22 | fun Lens.listIndex(i: Int): Optional where S : List { 23 | val index: Index = listIndex() as Index 24 | return index(index, i) 25 | } 26 | -------------------------------------------------------------------------------- /composable-architecture/src/main/kotlin/composablearchitecture/Effect+Cancellation.kt: -------------------------------------------------------------------------------- 1 | package composablearchitecture 2 | 3 | import kotlinx.coroutines.CoroutineStart 4 | import kotlinx.coroutines.Job 5 | import kotlinx.coroutines.async 6 | import kotlinx.coroutines.coroutineScope 7 | import kotlinx.coroutines.flow.flow 8 | import kotlinx.coroutines.flow.toList 9 | import kotlinx.coroutines.sync.Mutex 10 | import kotlinx.coroutines.sync.withLock 11 | 12 | private val mutex = Mutex() 13 | private val cancellationJobs: MutableMap> = mutableMapOf() 14 | 15 | fun Effect.cancellable(id: Any, cancelInFlight: Boolean = false): Effect = 16 | Effect(flow { 17 | if (cancelInFlight) { 18 | mutex.withLock { 19 | cancellationJobs[id]?.forEach { it.cancel() } 20 | cancellationJobs.remove(id) 21 | } 22 | } 23 | val outputs = coroutineScope { 24 | val deferred = async(start = CoroutineStart.LAZY) { this@cancellable.flow.toList() } 25 | mutex.withLock { 26 | @Suppress("RemoveExplicitTypeArguments") 27 | cancellationJobs.getOrPut(id) { mutableSetOf() }.add(deferred) 28 | } 29 | try { 30 | deferred.start() 31 | deferred.await() 32 | } finally { 33 | mutex.withLock { 34 | val jobs = cancellationJobs[id] 35 | jobs?.remove(deferred) 36 | if (jobs.isNullOrEmpty()) { 37 | cancellationJobs.remove(id) 38 | } 39 | } 40 | } 41 | } 42 | outputs.forEach { emit(it) } 43 | }) 44 | 45 | fun Effect.Companion.cancel(id: Any): Effect = Effect(flow { 46 | mutex.withLock { 47 | cancellationJobs[id]?.forEach { it.cancel() } 48 | cancellationJobs.remove(id) 49 | } 50 | }) 51 | 52 | fun State.cancel(id: Any): Result = 53 | Result(this, Effect.cancel(id)) 54 | 55 | fun Result.cancellable( 56 | id: Any, 57 | cancelInFlight: Boolean = false 58 | ): Result = 59 | Result(state, effect.cancellable(id, cancelInFlight)) 60 | -------------------------------------------------------------------------------- /composable-architecture/src/main/kotlin/composablearchitecture/Effect.kt: -------------------------------------------------------------------------------- 1 | package composablearchitecture 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.flow.FlowCollector 5 | import kotlinx.coroutines.flow.emptyFlow 6 | import kotlinx.coroutines.flow.flattenConcat 7 | import kotlinx.coroutines.flow.flattenMerge 8 | import kotlinx.coroutines.flow.flow 9 | import kotlinx.coroutines.flow.flowOf 10 | import kotlinx.coroutines.flow.map 11 | import kotlinx.coroutines.flow.toList 12 | 13 | class Effect(internal var flow: Flow) { 14 | 15 | companion object { 16 | operator fun invoke( 17 | block: suspend FlowCollector.() -> Unit 18 | ): Effect = Effect(flow(block)) 19 | 20 | fun none() = Effect(emptyFlow()) 21 | } 22 | 23 | fun map(transform: (Output) -> T): Effect = Effect(flow.map { transform(it) }) 24 | 25 | fun concatenate(vararg effects: Effect) { 26 | flow = flowOf(flow, *effects.map { it.flow }.toTypedArray()).flattenConcat() 27 | } 28 | 29 | fun merge(vararg effects: Effect) { 30 | flow = flowOf(flow, *effects.map { it.flow }.toTypedArray()).flattenMerge() 31 | } 32 | 33 | suspend fun sink(): List { 34 | val outputs = mutableListOf() 35 | flow.toList(outputs) 36 | return outputs 37 | } 38 | } 39 | 40 | fun State.withNoEffect(): Result = 41 | Result(this, Effect.none()) 42 | 43 | fun State.withEffect( 44 | block: suspend FlowCollector.() -> Unit 45 | ): Result = 46 | Result(this, Effect(flow(block))) 47 | -------------------------------------------------------------------------------- /composable-architecture/src/main/kotlin/composablearchitecture/Helpers.kt: -------------------------------------------------------------------------------- 1 | package composablearchitecture 2 | 3 | fun Iterable.update(index: Int, elem: E) = 4 | mapIndexed { i, existing -> if (i == index) elem else existing } 5 | -------------------------------------------------------------------------------- /composable-architecture/src/main/kotlin/composablearchitecture/Reducer+Debug.kt: -------------------------------------------------------------------------------- 1 | package composablearchitecture 2 | 3 | @Suppress("unused") 4 | fun Reducer.debug(): Reducer = 5 | Reducer { state, action, environment -> 6 | val result = run(state, action, environment) 7 | println("state=${result.state}, action=$action") 8 | result 9 | } 10 | -------------------------------------------------------------------------------- /composable-architecture/src/main/kotlin/composablearchitecture/Reducer.kt: -------------------------------------------------------------------------------- 1 | package composablearchitecture 2 | 3 | import arrow.optics.Getter 4 | import arrow.optics.Lens 5 | import arrow.optics.Prism 6 | 7 | data class Result(val state: State, val effect: Effect) 8 | 9 | class Reducer( 10 | val reducer: (State, Action, Environment) -> Result 11 | ) { 12 | companion object { 13 | fun combine(vararg reducers: Reducer) = 14 | Reducer { value, action, environment -> 15 | reducers.fold(Result(value, Effect.none())) { result, reducer -> 16 | val (currentValue, currentEffect) = result 17 | val (newValue, newEffect) = reducer.run(currentValue, action, environment) 18 | currentEffect.merge(newEffect) 19 | Result(newValue, currentEffect) 20 | } 21 | } 22 | } 23 | 24 | fun run( 25 | state: State, 26 | action: Action, 27 | environment: Environment 28 | ): Result = reducer(state, action, environment) 29 | 30 | fun combine(other: Reducer) = combine(this, other) 31 | 32 | fun pullback( 33 | toLocalState: Lens, 34 | toLocalAction: Prism, 35 | toLocalEnvironment: (GlobalEnvironment) -> Environment 36 | ): Reducer = 37 | Reducer { globalState, globalAction, globalEnvironment -> 38 | toLocalAction.getOrModify(globalAction).fold( 39 | { Result(globalState, Effect.none()) }, 40 | { localAction -> 41 | val (state, effect) = reducer( 42 | toLocalState.get(globalState), 43 | localAction, 44 | toLocalEnvironment(globalEnvironment) 45 | ) 46 | Result( 47 | toLocalState.set(globalState, state), 48 | effect.map(toLocalAction::reverseGet) 49 | ) 50 | } 51 | ) 52 | } 53 | 54 | fun forEach( 55 | toLocalState: Lens>, 56 | toLocalAction: Prism>, 57 | toLocalEnvironment: (GlobalEnvironment) -> Environment 58 | ): Reducer = 59 | Reducer { globalState, globalAction, globalEnvironment -> 60 | toLocalAction.getOrModify(globalAction).fold( 61 | { Result(globalState, Effect.none()) }, 62 | { (index, localAction) -> 63 | val localState = toLocalState.get(globalState) 64 | val (state, effect) = reducer( 65 | localState[index], 66 | localAction, 67 | toLocalEnvironment(globalEnvironment) 68 | ) 69 | Result( 70 | toLocalState.set(globalState, localState.update(index, state)), 71 | effect.map { toLocalAction.reverseGet(index to localAction) } 72 | ) 73 | } 74 | ) 75 | } 76 | 77 | fun forEach( 78 | toLocalState: Lens>, 79 | toLocalAction: Prism>, 80 | toLocalEnvironment: (GlobalEnvironment) -> Environment, 81 | idGetter: Getter 82 | ): Reducer = 83 | Reducer { globalState, globalAction, globalEnvironment -> 84 | toLocalAction.getOrModify(globalAction).fold( 85 | { Result(globalState, Effect.none()) }, 86 | { (id, localAction) -> 87 | val localState = toLocalState.get(globalState) 88 | val index = localState.indexOfFirst { idGetter.get(it) == id } 89 | if (index < 0) { 90 | Result(globalState, Effect.none()) 91 | } else { 92 | val (state, effect) = reducer( 93 | localState[index], 94 | localAction, 95 | toLocalEnvironment(globalEnvironment) 96 | ) 97 | Result( 98 | toLocalState.set(globalState, localState.update(index, state)), 99 | effect.map { toLocalAction.reverseGet(id to localAction) } 100 | ) 101 | } 102 | } 103 | ) 104 | } 105 | 106 | fun optional(): Reducer = Reducer { state, action, environment -> 107 | if (state == null) { 108 | Result(state, Effect.none()) 109 | } else { 110 | reducer(state, action, environment) 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /composable-architecture/src/main/kotlin/composablearchitecture/Store.kt: -------------------------------------------------------------------------------- 1 | package composablearchitecture 2 | 3 | import arrow.optics.Lens 4 | import arrow.optics.Prism 5 | import kotlinx.coroutines.CancellationException 6 | import kotlinx.coroutines.CoroutineDispatcher 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.GlobalScope 10 | import kotlinx.coroutines.Job 11 | import kotlinx.coroutines.flow.Flow 12 | import kotlinx.coroutines.flow.MutableStateFlow 13 | import kotlinx.coroutines.flow.collect 14 | import kotlinx.coroutines.launch 15 | import kotlinx.coroutines.withContext 16 | 17 | class Store private constructor( 18 | initialState: State, 19 | private val reducer: (State, Action) -> Result, 20 | private val mainDispatcher: CoroutineDispatcher 21 | ) { 22 | private val mutableState = MutableStateFlow(initialState) 23 | 24 | private var scopeCollectionJob: Job? = null 25 | 26 | val states: Flow = mutableState 27 | 28 | val currentState: State 29 | get() = mutableState.value 30 | 31 | companion object { 32 | operator fun invoke( 33 | initialState: State, 34 | reducer: Reducer, 35 | environment: Environment, 36 | mainDispatcher: CoroutineDispatcher = Dispatchers.Main 37 | ): Store = 38 | Store( 39 | initialState, 40 | { state, action -> reducer.run(state, action, environment) }, 41 | mainDispatcher 42 | ) 43 | } 44 | 45 | fun scope( 46 | toLocalState: Lens, 47 | fromLocalAction: Prism, 48 | coroutineScope: CoroutineScope 49 | ): Store { 50 | val localStore = Store( 51 | initialState = toLocalState.get(mutableState.value), 52 | reducer = { _, localAction -> 53 | send(fromLocalAction.reverseGet(localAction)) 54 | toLocalState.get(mutableState.value).withNoEffect() 55 | }, 56 | mainDispatcher = mainDispatcher 57 | ) 58 | localStore.scopeCollectionJob = coroutineScope.launch(Dispatchers.Unconfined) { 59 | mutableState.collect { newValue -> 60 | localStore.mutableState.value = toLocalState.get(newValue) 61 | } 62 | } 63 | return localStore 64 | } 65 | 66 | fun send(action: Action) { 67 | val currentThread = Thread.currentThread() 68 | require( 69 | currentThread.name.startsWith("main") || currentThread.name.contains("Test worker") 70 | ) { 71 | "Sending actions from background threads is not allowed" 72 | } 73 | 74 | val (newState, effect) = reducer(mutableState.value, action) 75 | mutableState.value = newState 76 | 77 | GlobalScope.launch(mainDispatcher) { 78 | try { 79 | val actions = effect.sink() 80 | if (actions.isNotEmpty()) { 81 | withContext(mainDispatcher) { 82 | actions.forEach { send(it) } 83 | } 84 | } 85 | } catch (ex: CancellationException) { 86 | // Ignore 87 | } 88 | } 89 | } 90 | 91 | fun cancel() { 92 | scopeCollectionJob?.cancel() 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /composable-architecture/src/test/kotlin/composablearchitecture/sandbox/Sandbox.kt: -------------------------------------------------------------------------------- 1 | package composablearchitecture.sandbox 2 | 3 | import arrow.core.left 4 | import arrow.core.right 5 | import arrow.optics.Prism 6 | import arrow.optics.optics 7 | import composablearchitecture.Reducer 8 | import composablearchitecture.Store 9 | import composablearchitecture.cancel 10 | import composablearchitecture.cancellable 11 | import composablearchitecture.test.TestStore 12 | import composablearchitecture.withEffect 13 | import composablearchitecture.withNoEffect 14 | import kotlinx.coroutines.delay 15 | import kotlinx.coroutines.flow.collect 16 | import kotlinx.coroutines.launch 17 | import kotlinx.coroutines.runBlocking 18 | import kotlinx.coroutines.test.TestCoroutineDispatcher 19 | 20 | @optics 21 | data class NestedState(var text: String = "") { 22 | companion object 23 | } 24 | 25 | @optics 26 | data class CounterState(val counter: Int = 0, val nestedState: NestedState = NestedState()) { 27 | companion object 28 | } 29 | 30 | sealed class CounterAction : Comparable { 31 | object Increment : CounterAction() 32 | object Noop : CounterAction() 33 | object Cancel : CounterAction() 34 | 35 | companion object { 36 | val prism: Prism = Prism( 37 | getOrModify = { appAction -> 38 | when (appAction) { 39 | is AppAction.Counter -> appAction.action.right() 40 | else -> appAction.left() 41 | } 42 | }, 43 | reverseGet = { counterAction -> 44 | AppAction.Counter( 45 | counterAction 46 | ) 47 | } 48 | ) 49 | } 50 | 51 | override fun compareTo(other: CounterAction): Int = this.compareTo(other) 52 | } 53 | 54 | class CounterEnvironment 55 | 56 | val counterReducer = 57 | Reducer { state, action, environment -> 58 | when (action) { 59 | CounterAction.Increment -> { 60 | state.copy(counter = state.counter + 1) 61 | .withEffect { 62 | delay(2000L) 63 | emit(CounterAction.Noop) 64 | } 65 | .cancellable("1", cancelInFlight = true) 66 | } 67 | CounterAction.Noop -> { 68 | println("Noop") 69 | state.withNoEffect() 70 | } 71 | CounterAction.Cancel -> state.cancel("1") 72 | } 73 | } 74 | 75 | @optics 76 | data class AppState(val counterState: CounterState = CounterState()) { 77 | companion object 78 | } 79 | 80 | sealed class AppAction : Comparable { 81 | object Reset : AppAction() 82 | data class Counter(val action: CounterAction) : AppAction() 83 | 84 | override fun compareTo(other: AppAction): Int = this.compareTo(other) 85 | } 86 | 87 | class AppEnvironment { 88 | var counterEnvironment = CounterEnvironment() 89 | } 90 | 91 | val appReducer = 92 | Reducer.combine( 93 | Reducer { state, action, _ -> 94 | when (action) { 95 | AppAction.Reset -> 96 | AppState.counterState.counter 97 | .set(state, 0) 98 | .withNoEffect() 99 | else -> state.withNoEffect() 100 | } 101 | }, 102 | counterReducer.pullback( 103 | AppState.counterState, 104 | CounterAction.prism 105 | ) { environment -> environment.counterEnvironment } 106 | ) 107 | 108 | fun main() { 109 | runBlocking { 110 | val testDispatcher = TestCoroutineDispatcher() 111 | 112 | val testStore = TestStore( 113 | AppState(), 114 | appReducer, 115 | AppEnvironment(), 116 | testDispatcher 117 | ) 118 | 119 | testStore.assert { 120 | send(AppAction.Counter(CounterAction.Increment)) { 121 | AppState(CounterState(counter = 1)) 122 | } 123 | send(AppAction.Counter(CounterAction.Increment)) { 124 | AppState(CounterState(counter = 2)) 125 | } 126 | send(AppAction.Reset) { 127 | AppState(CounterState(counter = 0)) 128 | } 129 | doBlock { 130 | testDispatcher.advanceTimeBy(2000L) 131 | } 132 | receive( 133 | AppAction.Counter(CounterAction.Noop) 134 | ) { 135 | AppState(CounterState(counter = 0)) 136 | } 137 | } 138 | 139 | println("✅") 140 | 141 | val store = Store( 142 | initialState = AppState(), 143 | reducer = appReducer, 144 | environment = AppEnvironment(), 145 | mainDispatcher = testDispatcher 146 | ) 147 | 148 | val scopedStore = store.scope( 149 | toLocalState = AppState.counterState, 150 | fromLocalAction = CounterAction.prism, 151 | coroutineScope = this 152 | ) 153 | 154 | val job1 = launch(testDispatcher) { 155 | store.states.collect { 156 | println("[${Thread.currentThread().name}] [global store] state=$it") 157 | } 158 | } 159 | 160 | val job2 = launch(testDispatcher) { 161 | scopedStore.states.collect { 162 | println("[${Thread.currentThread().name}] [scoped store] state=$it") 163 | } 164 | } 165 | 166 | store.send(AppAction.Counter(CounterAction.Increment)) 167 | store.send(AppAction.Counter(CounterAction.Increment)) 168 | store.send(AppAction.Counter(CounterAction.Increment)) 169 | 170 | testDispatcher.advanceTimeBy(2000L) 171 | 172 | job1.cancel() 173 | job2.cancel() 174 | scopedStore.cancel() 175 | 176 | println("✅") 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /composable-architecture/src/test/kotlin/composablearchitecture/sandbox/optional/Sandbox.kt: -------------------------------------------------------------------------------- 1 | package composablearchitecture.sandbox.optional 2 | 3 | import arrow.optics.Prism 4 | import arrow.optics.optics 5 | import composablearchitecture.Reducer 6 | import composablearchitecture.Store 7 | import composablearchitecture.withNoEffect 8 | import kotlinx.coroutines.flow.collect 9 | import kotlinx.coroutines.launch 10 | import kotlinx.coroutines.runBlocking 11 | import kotlinx.coroutines.test.TestCoroutineDispatcher 12 | 13 | @optics 14 | data class AppState(val text: String? = null) { 15 | companion object 16 | } 17 | 18 | sealed class AppAction { 19 | data class UpdateText(val to: String) : AppAction() 20 | } 21 | 22 | val optionalReducer = Reducer { _, action, _ -> 23 | when (action) { 24 | is AppAction.UpdateText -> action.to.withNoEffect() 25 | } 26 | }.optional() 27 | 28 | val appReducer: Reducer = optionalReducer.pullback( 29 | toLocalState = AppState.nullableText, 30 | toLocalAction = Prism.id(), 31 | toLocalEnvironment = { Unit } 32 | ) 33 | 34 | fun main() { 35 | runBlocking { 36 | val testDispatcher = TestCoroutineDispatcher() 37 | 38 | println("🎬 initial state is non-null") 39 | 40 | var store = Store( 41 | initialState = AppState(text = ""), 42 | reducer = appReducer, 43 | environment = Unit, 44 | mainDispatcher = testDispatcher 45 | ) 46 | var job = launch(testDispatcher) { store.states.collect { println(it) } } 47 | store.send(AppAction.UpdateText("Update non-null state")) 48 | job.cancel() 49 | 50 | println("🎬 initial state is null") 51 | 52 | store = Store( 53 | initialState = AppState(text = null), 54 | reducer = appReducer, 55 | environment = Unit, 56 | mainDispatcher = testDispatcher 57 | ) 58 | job = launch(testDispatcher) { store.states.collect { println(it) } } 59 | store.send(AppAction.UpdateText("Update null state")) 60 | job.cancel() 61 | 62 | println("✅") 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /examples/case-studies/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /examples/case-studies/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("shared-android") 3 | } 4 | 5 | android { 6 | defaultConfig { 7 | applicationId = "composablearchitecture.example.casestudies" 8 | } 9 | } 10 | 11 | dependencies { 12 | implementation("androidx.dynamicanimation:dynamicanimation:$androidxDynamicAnimationVersion") 13 | } 14 | -------------------------------------------------------------------------------- /examples/case-studies/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /examples/case-studies/src/androidTest/kotlin/composablearchitecture/example/casestudies/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package composablearchitecture.example.casestudies 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import org.junit.Assert.assertEquals 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | @RunWith(AndroidJUnit4::class) 10 | class ExampleInstrumentedTest { 11 | @Test 12 | fun useAppContext() { 13 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 14 | assertEquals("composablearchitecture.example.casestudies", appContext.packageName) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/case-studies/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /examples/case-studies/src/main/kotlin/composablearchitecture/example/casestudies/AnimationActivity.kt: -------------------------------------------------------------------------------- 1 | package composablearchitecture.example.casestudies 2 | 3 | import android.annotation.SuppressLint 4 | import android.os.Bundle 5 | import android.view.MotionEvent 6 | import android.view.View 7 | import androidx.appcompat.app.AppCompatActivity 8 | import androidx.core.view.doOnLayout 9 | import androidx.databinding.DataBindingUtil 10 | import androidx.dynamicanimation.animation.DynamicAnimation 11 | import androidx.dynamicanimation.animation.SpringAnimation 12 | import androidx.dynamicanimation.animation.SpringForce 13 | import androidx.lifecycle.lifecycleScope 14 | import composablearchitecture.Reducer 15 | import composablearchitecture.Result 16 | import composablearchitecture.Store 17 | import composablearchitecture.example.casestudies.databinding.MainActivityBinding 18 | import composablearchitecture.withNoEffect 19 | import kotlinx.coroutines.flow.collect 20 | 21 | data class EventWrapper( 22 | val x: Float = 0F, 23 | val y: Float = 0F, 24 | val action: Int = 0 25 | ) 26 | 27 | data class State( 28 | val dx: Float = 0F, 29 | val dy: Float = 0F, 30 | val event: EventWrapper = EventWrapper(), 31 | val animationX: SpringAnimation? = null, 32 | val animationY: SpringAnimation? = null 33 | ) 34 | 35 | sealed class Action { 36 | data class Touch(val view: View, val event: MotionEvent) : Action() 37 | data class Layout(val view: View) : Action() 38 | } 39 | 40 | private val reducer = Reducer { state, action, _ -> 41 | when (action) { 42 | is Action.Layout -> action.handle(state) 43 | is Action.Touch -> action.handle(state) 44 | } 45 | } 46 | 47 | private fun Action.Layout.handle(state: State): Result { 48 | val animationX = createAnimation(view, view.x, SpringAnimation.X) 49 | val animationY = createAnimation(view, view.y, SpringAnimation.Y) 50 | return state 51 | .copy(animationX = animationX, animationY = animationY) 52 | .withNoEffect() 53 | } 54 | 55 | private fun Action.Touch.handle(state: State): Result = 56 | when (event.actionMasked) { 57 | MotionEvent.ACTION_DOWN -> { 58 | state.animationX?.cancel() 59 | state.animationY?.cancel() 60 | state 61 | .copy( 62 | dx = view.x - event.rawX, 63 | dy = view.y - event.rawY, 64 | event = event.wrap() 65 | ) 66 | .withNoEffect() 67 | } 68 | MotionEvent.ACTION_MOVE -> 69 | state 70 | .copy(event = event.wrap()) 71 | .withNoEffect() 72 | MotionEvent.ACTION_UP -> { 73 | state.animationX?.start() 74 | state.animationY?.start() 75 | state.withNoEffect() 76 | } 77 | else -> state.withNoEffect() 78 | } 79 | 80 | private val store = Store(State(), reducer, Unit) 81 | 82 | @SuppressLint("ClickableViewAccessibility") 83 | class AnimationActivity : AppCompatActivity() { 84 | 85 | override fun onCreate(savedInstanceState: Bundle?) { 86 | super.onCreate(savedInstanceState) 87 | 88 | val binding = DataBindingUtil.setContentView(this, R.layout.main_activity) 89 | val imageView = binding.animationImageView 90 | 91 | imageView.setOnTouchListener { view, event -> 92 | store.send(Action.Touch(view, event)) 93 | true 94 | } 95 | 96 | imageView.doOnLayout { view -> 97 | store.send(Action.Layout(view)) 98 | } 99 | 100 | lifecycleScope.launchWhenCreated { 101 | store.states.collect { state -> 102 | when (state.event.action) { 103 | MotionEvent.ACTION_MOVE -> { 104 | imageView.animate() 105 | .x(state.event.x + state.dx) 106 | .y(state.event.y + state.dy) 107 | .setDuration(0) 108 | .start() 109 | } 110 | } 111 | } 112 | } 113 | } 114 | } 115 | 116 | private fun MotionEvent.wrap(): EventWrapper = EventWrapper(rawX, rawY, actionMasked) 117 | 118 | private fun createAnimation(view: View, position: Float, property: DynamicAnimation.ViewProperty): SpringAnimation = 119 | SpringAnimation(view, property).apply { 120 | val force = SpringForce(position).apply { 121 | stiffness = SpringForce.STIFFNESS_MEDIUM 122 | dampingRatio = SpringForce.DAMPING_RATIO_HIGH_BOUNCY 123 | } 124 | spring = force 125 | } 126 | -------------------------------------------------------------------------------- /examples/case-studies/src/main/kotlin/composablearchitecture/example/casestudies/CaseStudiesApp.kt: -------------------------------------------------------------------------------- 1 | package composablearchitecture.example.casestudies 2 | 3 | import android.app.Application 4 | 5 | class CaseStudiesApp : Application() 6 | -------------------------------------------------------------------------------- /examples/case-studies/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | 31 | -------------------------------------------------------------------------------- /examples/case-studies/src/main/res/drawable/circle.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /examples/case-studies/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 | -------------------------------------------------------------------------------- /examples/case-studies/src/main/res/layout/main_activity.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 9 | 10 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /examples/case-studies/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /examples/case-studies/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /examples/case-studies/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wearemakery/kotlin-composable-architecture/17cba7b90779b3ec52ffda225df0c2d4befd2ed8/examples/case-studies/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /examples/case-studies/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wearemakery/kotlin-composable-architecture/17cba7b90779b3ec52ffda225df0c2d4befd2ed8/examples/case-studies/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /examples/case-studies/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wearemakery/kotlin-composable-architecture/17cba7b90779b3ec52ffda225df0c2d4befd2ed8/examples/case-studies/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /examples/case-studies/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wearemakery/kotlin-composable-architecture/17cba7b90779b3ec52ffda225df0c2d4befd2ed8/examples/case-studies/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /examples/case-studies/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wearemakery/kotlin-composable-architecture/17cba7b90779b3ec52ffda225df0c2d4befd2ed8/examples/case-studies/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /examples/case-studies/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wearemakery/kotlin-composable-architecture/17cba7b90779b3ec52ffda225df0c2d4befd2ed8/examples/case-studies/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /examples/case-studies/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wearemakery/kotlin-composable-architecture/17cba7b90779b3ec52ffda225df0c2d4befd2ed8/examples/case-studies/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /examples/case-studies/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wearemakery/kotlin-composable-architecture/17cba7b90779b3ec52ffda225df0c2d4befd2ed8/examples/case-studies/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /examples/case-studies/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wearemakery/kotlin-composable-architecture/17cba7b90779b3ec52ffda225df0c2d4befd2ed8/examples/case-studies/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /examples/case-studies/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wearemakery/kotlin-composable-architecture/17cba7b90779b3ec52ffda225df0c2d4befd2ed8/examples/case-studies/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /examples/case-studies/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #6200EE 4 | #3700B3 5 | #03DAC5 6 | 7 | -------------------------------------------------------------------------------- /examples/case-studies/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Case Studies 3 | 4 | -------------------------------------------------------------------------------- /examples/case-studies/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /examples/search/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /examples/search/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("shared-android") 3 | } 4 | 5 | android { 6 | defaultConfig { 7 | applicationId = "composablearchitecture.example.search" 8 | } 9 | @Suppress("UnstableApiUsage") 10 | buildFeatures { 11 | compose = true 12 | aidl = false 13 | renderScript = false 14 | shaders = false 15 | } 16 | composeOptions { 17 | kotlinCompilerExtensionVersion = kotlinComposeVersion 18 | } 19 | } 20 | 21 | dependencies { 22 | implementation("androidx.activity:activity-compose:$kotlinComposeVersion") 23 | implementation("androidx.compose.foundation:foundation:$kotlinComposeVersion") 24 | implementation("androidx.compose.material:material:$kotlinComposeVersion") 25 | implementation("androidx.compose.ui:ui-tooling:$kotlinComposeVersion") 26 | implementation("androidx.compose.ui:ui:$kotlinComposeVersion") 27 | implementation("com.squareup.okhttp3:okhttp:$okhttpVersion") 28 | implementation("com.squareup.retrofit2:converter-moshi:$retrofitVersion") 29 | implementation("com.squareup.retrofit2:retrofit:$retrofitVersion") 30 | } 31 | 32 | configurations.all { 33 | resolutionStrategy.force("com.squareup.okhttp3:okhttp:$okhttpVersion") 34 | } 35 | -------------------------------------------------------------------------------- /examples/search/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /examples/search/src/androidTest/kotlin/composablearchitecture/example/search/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package composablearchitecture.example.search 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import org.junit.Assert.assertEquals 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | @RunWith(AndroidJUnit4::class) 10 | class ExampleInstrumentedTest { 11 | @Test 12 | fun useAppContext() { 13 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 14 | assertEquals("composablearchitecture.example.search", appContext.packageName) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/search/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 15 | 16 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /examples/search/src/main/kotlin/composablearchitecture/example/search/ComposeSearchActivity.kt: -------------------------------------------------------------------------------- 1 | package composablearchitecture.example.search 2 | 3 | import android.os.Bundle 4 | import androidx.activity.compose.setContent 5 | import androidx.appcompat.app.AppCompatActivity 6 | import androidx.compose.foundation.clickable 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.foundation.layout.Column 9 | import androidx.compose.foundation.layout.Spacer 10 | import androidx.compose.foundation.layout.fillMaxWidth 11 | import androidx.compose.foundation.layout.height 12 | import androidx.compose.foundation.layout.padding 13 | import androidx.compose.foundation.lazy.LazyColumn 14 | import androidx.compose.foundation.lazy.items 15 | import androidx.compose.material.Divider 16 | import androidx.compose.material.MaterialTheme 17 | import androidx.compose.material.Text 18 | import androidx.compose.material.TextField 19 | import androidx.compose.runtime.Composable 20 | import androidx.compose.runtime.LaunchedEffect 21 | import androidx.compose.runtime.mutableStateOf 22 | import androidx.compose.runtime.remember 23 | import androidx.compose.ui.Modifier 24 | import androidx.compose.ui.graphics.Color 25 | import androidx.compose.ui.res.stringResource 26 | import androidx.compose.ui.unit.dp 27 | import composablearchitecture.Store 28 | import kotlinx.coroutines.flow.collect 29 | 30 | class ComposeSearchActivity : AppCompatActivity() { 31 | 32 | override fun onCreate(savedInstanceState: Bundle?) { 33 | super.onCreate(savedInstanceState) 34 | setContent { 35 | SearchView( 36 | store = SearchApp.store, 37 | readMe = stringResource(R.string.read_me) 38 | ) 39 | } 40 | } 41 | } 42 | 43 | @Composable 44 | fun SearchView(store: Store, readMe: String) { 45 | val state = remember { mutableStateOf(store.currentState) } 46 | 47 | LaunchedEffect("Store") { 48 | store.states.collect { 49 | state.value = it 50 | } 51 | } 52 | 53 | MaterialTheme { 54 | Box(Modifier.padding(16.dp)) { 55 | Column { 56 | Text(text = readMe) 57 | Spacer(modifier = Modifier.height(16.dp)) 58 | TextField( 59 | value = state.value.searchQuery, 60 | onValueChange = { store.send(SearchAction.SearchQueryChanged(it)) }, 61 | modifier = Modifier.fillMaxWidth() 62 | ) 63 | Divider(color = Color.Black) 64 | Spacer(modifier = Modifier.height(16.dp)) 65 | LazyColumn { 66 | items(state.value.locations) { location -> 67 | Text( 68 | text = location.title, 69 | modifier = Modifier.clickable { 70 | store.send(SearchAction.LocationTapped(location)) 71 | }) 72 | } 73 | } 74 | } 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /examples/search/src/main/kotlin/composablearchitecture/example/search/LocalDateAdapter.kt: -------------------------------------------------------------------------------- 1 | package composablearchitecture.example.search 2 | 3 | import com.squareup.moshi.FromJson 4 | import com.squareup.moshi.ToJson 5 | import java.time.LocalDate 6 | import java.time.format.DateTimeFormatter 7 | 8 | object LocalDateAdapter { 9 | 10 | private val formatter = DateTimeFormatter.ISO_LOCAL_DATE 11 | 12 | @FromJson 13 | fun fromJson(json: String): LocalDate = LocalDate.parse(json, formatter) 14 | 15 | @ToJson 16 | fun toJson(date: LocalDate): String = formatter.format(date) 17 | } 18 | -------------------------------------------------------------------------------- /examples/search/src/main/kotlin/composablearchitecture/example/search/Search.kt: -------------------------------------------------------------------------------- 1 | package composablearchitecture.example.search 2 | 3 | import arrow.core.Either 4 | import arrow.optics.optics 5 | import composablearchitecture.Reducer 6 | import composablearchitecture.Result 7 | import composablearchitecture.cancel 8 | import composablearchitecture.cancellable 9 | import composablearchitecture.debug 10 | import composablearchitecture.withEffect 11 | import composablearchitecture.withNoEffect 12 | import kotlinx.coroutines.delay 13 | 14 | @optics 15 | data class SearchState( 16 | val locations: List = emptyList(), 17 | val locationWeather: LocationWeather? = null, 18 | val locationWeatherRequestInFlight: Location? = null, 19 | val searchQuery: String = "" 20 | ) { 21 | companion object 22 | } 23 | 24 | sealed class SearchAction : Comparable { 25 | data class LocationsResponse(val result: Either>) : SearchAction() 26 | data class LocationTapped(val location: Location) : SearchAction() 27 | data class LocationWeatherResponse(val result: Either) : SearchAction() 28 | data class SearchQueryChanged(val query: String) : SearchAction() 29 | 30 | override fun compareTo(other: SearchAction): Int = this.compareTo(other) 31 | } 32 | 33 | class SearchEnvironment(var weatherClient: WeatherClient = LiveWeatherClient()) 34 | 35 | val searchReducer = Reducer { state, action, environment -> 36 | when (action) { 37 | is SearchAction.LocationsResponse -> action.handle(state) 38 | is SearchAction.LocationTapped -> action.handle(state, environment) 39 | is SearchAction.SearchQueryChanged -> action.handle(state, environment) 40 | is SearchAction.LocationWeatherResponse -> action.handle(state) 41 | } 42 | }.debug() 43 | 44 | private fun SearchAction.LocationsResponse.handle(state: SearchState): Result = 45 | result.fold( 46 | { error -> 47 | println(error.message) 48 | state.copy(locations = emptyList()).withNoEffect() 49 | }, 50 | { locations -> state.copy(locations = locations).withNoEffect() } 51 | ) 52 | 53 | private fun SearchAction.LocationTapped.handle( 54 | state: SearchState, 55 | environment: SearchEnvironment 56 | ): Result = 57 | state 58 | .copy(locationWeatherRequestInFlight = location) 59 | .withEffect { 60 | val result = environment.weatherClient.weather(location.id) 61 | emit(SearchAction.LocationWeatherResponse(result)) 62 | } 63 | .cancellable("SearchWeatherId", cancelInFlight = true) 64 | 65 | private fun SearchAction.SearchQueryChanged.handle( 66 | state: SearchState, 67 | environment: SearchEnvironment 68 | ): Result { 69 | val newState = state.copy(searchQuery = query) 70 | return if (query.isBlank()) { 71 | newState 72 | .copy(locations = emptyList(), locationWeather = null) 73 | .cancel("SearchLocationId") 74 | } else { 75 | // TODO: implement debounce to combine delay and cancellable 76 | newState 77 | .withEffect { 78 | delay(300L) 79 | val result = environment.weatherClient.searchLocation(newState.searchQuery) 80 | emit(SearchAction.LocationsResponse(result)) 81 | } 82 | .cancellable("SearchLocationId", cancelInFlight = true) 83 | } 84 | } 85 | 86 | private fun SearchAction.LocationWeatherResponse.handle(state: SearchState): Result = 87 | result.fold( 88 | { error -> 89 | println(error.message) 90 | state 91 | .copy(locationWeather = null, locationWeatherRequestInFlight = null) 92 | .withNoEffect() 93 | }, 94 | { locationWeather -> 95 | state 96 | .copy(locationWeather = locationWeather, locationWeatherRequestInFlight = null) 97 | .withNoEffect() 98 | } 99 | ) 100 | -------------------------------------------------------------------------------- /examples/search/src/main/kotlin/composablearchitecture/example/search/SearchActivity.kt: -------------------------------------------------------------------------------- 1 | package composablearchitecture.example.search 2 | 3 | import android.content.Intent 4 | import android.net.Uri 5 | import android.os.Bundle 6 | import androidx.appcompat.app.AppCompatActivity 7 | import androidx.core.widget.doAfterTextChanged 8 | import androidx.databinding.DataBindingUtil 9 | import androidx.lifecycle.lifecycleScope 10 | import androidx.recyclerview.widget.DividerItemDecoration 11 | import androidx.recyclerview.widget.LinearLayoutManager 12 | import composablearchitecture.example.search.databinding.SearchActivityBinding 13 | import kotlinx.coroutines.flow.collect 14 | 15 | class SearchActivity : AppCompatActivity() { 16 | 17 | private val store = SearchApp.store 18 | 19 | override fun onCreate(savedInstanceState: Bundle?) { 20 | super.onCreate(savedInstanceState) 21 | 22 | val binding = DataBindingUtil 23 | .setContentView(this, R.layout.search_activity) 24 | binding.lifecycleOwner = this 25 | 26 | binding.searchInput.doAfterTextChanged { text -> 27 | store.send(SearchAction.SearchQueryChanged(text.toString())) 28 | } 29 | 30 | val searchAdapter = SearchAdapter(onLocationTap = { store.send(SearchAction.LocationTapped(it)) }) 31 | binding.searchResults.apply { 32 | adapter = searchAdapter 33 | layoutManager = LinearLayoutManager(context) 34 | addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) 35 | } 36 | 37 | binding.searchCreditButton.setOnClickListener { 38 | startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://www.metaweather.com/"))) 39 | } 40 | 41 | lifecycleScope.launchWhenCreated { 42 | store.states.collect { state -> searchAdapter.update(state) } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /examples/search/src/main/kotlin/composablearchitecture/example/search/SearchAdapter.kt: -------------------------------------------------------------------------------- 1 | package composablearchitecture.example.search 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.recyclerview.widget.RecyclerView 6 | import composablearchitecture.example.search.databinding.LocationItemBinding 7 | import kotlin.math.roundToInt 8 | 9 | class LocationViewHolder( 10 | private val binding: LocationItemBinding 11 | ) : RecyclerView.ViewHolder(binding.root) { 12 | 13 | fun bind(newLocation: Location, state: SearchState) { 14 | if (state.locationWeather != null && state.locationWeather.id == newLocation.id) { 15 | val weather = state.locationWeather.consolidatedWeather 16 | .joinToString("\n") { 17 | " ${it.applicableDate.dayOfWeek}, ${it.theTemp.roundToInt()}℃, ${it.weatherStateName}" 18 | } 19 | binding.weatherText = "${newLocation.title}\n${weather}" 20 | } else { 21 | binding.weatherText = newLocation.title 22 | } 23 | binding.showProgress = newLocation == state.locationWeatherRequestInFlight 24 | binding.location = newLocation 25 | binding.executePendingBindings() 26 | } 27 | } 28 | 29 | class SearchAdapter( 30 | val onLocationTap: (Location) -> Unit 31 | ) : RecyclerView.Adapter() { 32 | 33 | private lateinit var state: SearchState 34 | 35 | fun update(newState: SearchState) { 36 | state = newState 37 | notifyDataSetChanged() 38 | } 39 | 40 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LocationViewHolder { 41 | val inflater = LayoutInflater.from(parent.context) 42 | val binding = LocationItemBinding.inflate(inflater, parent, false) 43 | val holder = LocationViewHolder(binding) 44 | binding.adapter = this 45 | return holder 46 | } 47 | 48 | override fun getItemCount(): Int = state.locations.size 49 | 50 | override fun onBindViewHolder(holder: LocationViewHolder, position: Int) { 51 | holder.bind(state.locations[position], state) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /examples/search/src/main/kotlin/composablearchitecture/example/search/SearchApp.kt: -------------------------------------------------------------------------------- 1 | package composablearchitecture.example.search 2 | 3 | import android.app.Application 4 | import composablearchitecture.Store 5 | 6 | @Suppress("unused") 7 | class SearchApp : Application() { 8 | 9 | companion object { 10 | val store = Store( 11 | SearchState(), 12 | searchReducer, 13 | SearchEnvironment() 14 | ) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/search/src/main/kotlin/composablearchitecture/example/search/WeatherClient.kt: -------------------------------------------------------------------------------- 1 | package composablearchitecture.example.search 2 | 3 | import arrow.core.Either 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.Moshi 6 | import kotlinx.coroutines.runBlocking 7 | import okhttp3.Dispatcher 8 | import okhttp3.OkHttpClient 9 | import retrofit2.Retrofit 10 | import retrofit2.converter.moshi.MoshiConverterFactory 11 | import retrofit2.http.GET 12 | import retrofit2.http.Path 13 | import retrofit2.http.Query 14 | import java.time.LocalDate 15 | import java.util.concurrent.ExecutorService 16 | 17 | data class Location( 18 | @field:Json(name = "woeid") val id: Int, 19 | val title: String 20 | ) 21 | 22 | data class LocationWeather( 23 | @field:Json(name = "consolidated_weather") var consolidatedWeather: List, 24 | @field:Json(name = "woeid") var id: Int 25 | ) 26 | 27 | data class ConsolidatedWeather( 28 | @field:Json(name = "applicable_date") val applicableDate: LocalDate, 29 | @field:Json(name = "max_temp") val maxTemp: Double, 30 | @field:Json(name = "min_temp") val minTemp: Double, 31 | @field:Json(name = "the_temp") val theTemp: Double, 32 | @field:Json(name = "weather_state_name") val weatherStateName: String? 33 | ) 34 | 35 | private interface WeatherClientApi { 36 | 37 | @GET("location/search/") 38 | suspend fun search(@Query("query") query: String): List 39 | 40 | @GET("location/{id}/") 41 | suspend fun weather(@Path("id") id: Int): LocationWeather 42 | } 43 | 44 | interface WeatherClient { 45 | 46 | var searchLocation: suspend (String) -> Either> 47 | 48 | var weather: suspend (Int) -> Either 49 | } 50 | 51 | class LiveWeatherClient(executor: ExecutorService? = null) : WeatherClient { 52 | 53 | private val httpClient = OkHttpClient.Builder() 54 | .apply { executor?.let { dispatcher(Dispatcher(it)) } } 55 | .build() 56 | 57 | private val moshi = Moshi.Builder() 58 | .add(LocalDateAdapter) 59 | .build() 60 | 61 | private val retrofit = Retrofit.Builder() 62 | .client(httpClient) 63 | .baseUrl("https://www.metaweather.com/api/") 64 | .addConverterFactory(MoshiConverterFactory.create(moshi)) 65 | .build() 66 | 67 | private val weatherClientApi = retrofit.create(WeatherClientApi::class.java) 68 | 69 | fun shutdown() { 70 | httpClient.dispatcher.executorService.shutdown() 71 | } 72 | 73 | override var searchLocation: suspend (String) -> Either> = { query -> 74 | Either.catch { 75 | weatherClientApi.search(query) 76 | } 77 | } 78 | 79 | override var weather: suspend (Int) -> Either = { id -> 80 | Either.catch { 81 | weatherClientApi.weather(id) 82 | } 83 | } 84 | } 85 | 86 | fun main() { 87 | runBlocking { 88 | val client = LiveWeatherClient() 89 | client.searchLocation("San").fold( 90 | { error -> println(error.message) }, 91 | { locations -> 92 | val weather = client.weather(locations[0].id) 93 | println(weather) 94 | } 95 | ) 96 | client.shutdown() 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /examples/search/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | 31 | -------------------------------------------------------------------------------- /examples/search/src/main/res/drawable/ic_baseline_add_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /examples/search/src/main/res/drawable/ic_baseline_search_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /examples/search/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 | -------------------------------------------------------------------------------- /examples/search/src/main/res/layout/location_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 18 | 19 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 36 | 37 | 40 | 41 | 44 | 45 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /examples/search/src/main/res/layout/search_activity.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 11 | 12 | 19 | 20 | 28 | 29 | 35 | 36 |