├── .github └── workflows │ └── push-build.yml ├── .gitignore ├── .idea ├── codeStyles │ └── Project.xml └── fileTemplates │ └── TEA Feature.kt ├── LICENSE.txt ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── kotlin │ └── io │ │ └── github │ │ └── mishkun │ │ └── puerh │ │ └── sampleapp │ │ ├── MainActivity.kt │ │ ├── MyApplication.kt │ │ ├── backstack │ │ ├── logic │ │ │ ├── BackstackFeature.kt │ │ │ └── ScreenNames.kt │ │ └── ui │ │ │ └── BackstackScreen.kt │ │ ├── counter │ │ ├── data │ │ │ └── RandomEffectHandler.kt │ │ ├── logic │ │ │ └── CounterFeature.kt │ │ └── ui │ │ │ └── CounterScreen.kt │ │ ├── di │ │ └── FeatureProvider.kt │ │ ├── toplevel │ │ ├── logic │ │ │ └── TopLevelFeature.kt │ │ └── ui │ │ │ └── TopLevelScreen.kt │ │ ├── translate │ │ ├── data │ │ │ └── TranslationApiEffectHandler.kt │ │ ├── logic │ │ │ ├── ApiError.kt │ │ │ ├── Language.kt │ │ │ └── TranslateFeature.kt │ │ └── ui │ │ │ └── TranslateScreen.kt │ │ └── util │ │ └── CollectionUtils.kt │ └── res │ ├── drawable-v24 │ └── ic_launcher_foreground.xml │ ├── drawable │ ├── ic_baseline_backstack_24.xml │ ├── ic_baseline_counter_24.xml │ ├── ic_baseline_translate_24.xml │ ├── ic_interchange_24.xml │ └── ic_launcher_background.xml │ ├── menu │ └── bottom_navigation.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 ├── build.gradle.kts ├── buildSrc ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── Config.kt ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── puerh-core ├── .gitignore ├── build.gradle.kts ├── consumer-rules.pro ├── proguard-rules.pro └── src │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── io │ │ │ └── github │ │ │ └── mishkun │ │ │ └── puerh │ │ │ ├── core │ │ │ ├── Cancelable.kt │ │ │ ├── EffectHandler.kt │ │ │ ├── Feature.kt │ │ │ ├── SyncFeature.kt │ │ │ └── utils │ │ │ │ └── General.kt │ │ │ └── handlers │ │ │ └── sync │ │ │ └── SyncEffectHandler.kt │ └── res │ │ └── values │ │ └── strings.xml │ └── test │ └── java │ └── io │ └── github │ └── mishkun │ └── puerh │ ├── core │ └── SyncFeatureSpec.kt │ └── handlers │ └── sync │ └── SyncEffectHandlerSpec.kt ├── puerh-jvm-executors ├── .gitignore ├── README.md ├── build.gradle.kts ├── consumer-rules.pro ├── proguard-rules.pro └── src │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── io │ │ │ └── github │ │ │ └── mishkun │ │ │ └── puerh │ │ │ └── handlers │ │ │ └── executor │ │ │ └── ExecutorEffectHandler.kt │ └── res │ │ └── values │ │ └── strings.xml │ └── test │ └── java │ └── io │ └── github │ └── mishkun │ └── puerh │ └── handlers │ └── ExecutorEffectHandlerSpec.kt └── settings.gradle /.github/workflows/push-build.yml: -------------------------------------------------------------------------------- 1 | name: Test and Lint on push 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: gradle/wrapper-validation-action@v1 11 | 12 | - name: Setup JDK 13 | uses: actions/setup-java@v1 14 | with: 15 | java-version: 1.8 16 | 17 | - name: Cache Gradle dependencies 18 | uses: actions/cache@v1 19 | with: 20 | path: ~/.gradle/wrapper 21 | key: wrapper-${{ runner.os }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties') }} 22 | 23 | - name: Cache Gradle dependencies 24 | uses: actions/cache@v1 25 | with: 26 | path: ~/.gradle/caches 27 | key: ${{ runner.os }}-gradle-${{ hashFiles('**/build.gradle.kts') }} 28 | restore-keys: | 29 | ${{ runner.os }}-gradle- 30 | 31 | - name: Run lint check 32 | run: ./gradlew lint 33 | 34 | - uses: actions/upload-artifact@v2 35 | with: 36 | name: lint-report.html 37 | path: app/build/reports/lint-results.html 38 | 39 | - name: Run lint check 40 | run: ./gradlew lint 41 | 42 | - uses: actions/upload-artifact@v2 43 | with: 44 | name: test-results.html 45 | path: "*/build/reports/lint-results.html" 46 | 47 | - name: Run tests 48 | run: ./gradlew test 49 | 50 | - uses: actions/upload-artifact@v2 51 | with: 52 | name: test-results.html 53 | path: "*/build/reports/tests/testDebugUnitTest/**" 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | *.apk 3 | *.ap_ 4 | *.aab 5 | 6 | # Files for the ART/Dalvik VM 7 | *.dex 8 | 9 | # Java class files 10 | *.class 11 | 12 | # Generated files 13 | bin/ 14 | gen/ 15 | out/ 16 | release/ 17 | 18 | # Gradle files 19 | .gradle/ 20 | build/ 21 | 22 | # Local configuration file (sdk path, etc) 23 | local.properties 24 | 25 | # Log Files 26 | *.log 27 | 28 | # Android Studio Navigation editor temp files 29 | .navigation/ 30 | 31 | # Android Studio captures folder 32 | captures/ 33 | 34 | # IntelliJ 35 | *.iml 36 | .idea/workspace.xml 37 | .idea/tasks.xml 38 | .idea/gradle.xml 39 | .idea/assetWizardSettings.xml 40 | .idea/dictionaries 41 | .idea/libraries 42 | # Android Studio 3 in .gitignore file. 43 | .idea/caches 44 | .idea/modules.xml 45 | # Comment next line if keeping position of elements in Navigation Editor is relevant for you 46 | .idea/navEditor.xml 47 | 48 | # Keystore files 49 | # Uncomment the following lines if you do not want to check your keystore files in. 50 | #*.jks 51 | #*.keystore 52 | 53 | # External native build folder generated in Android Studio 2.2 and later 54 | .externalNativeBuild 55 | 56 | # Version control 57 | vcs.xml 58 | 59 | # lint 60 | lint/intermediates/ 61 | lint/generated/ 62 | lint/outputs/ 63 | lint/tmp/ 64 | # lint/reports/ 65 | .idea/misc.xml 66 | .idea/encodings.xml 67 | .idea/codeStyles/codeStyleConfig.xml 68 | /.idea/jarRepositories.xml 69 | /.idea/.name 70 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | xmlns:android 17 | 18 | ^$ 19 | 20 | 21 | 22 |
23 |
24 | 25 | 26 | 27 | xmlns:.* 28 | 29 | ^$ 30 | 31 | 32 | BY_NAME 33 | 34 |
35 |
36 | 37 | 38 | 39 | .*:id 40 | 41 | http://schemas.android.com/apk/res/android 42 | 43 | 44 | 45 |
46 |
47 | 48 | 49 | 50 | .*:name 51 | 52 | http://schemas.android.com/apk/res/android 53 | 54 | 55 | 56 |
57 |
58 | 59 | 60 | 61 | name 62 | 63 | ^$ 64 | 65 | 66 | 67 |
68 |
69 | 70 | 71 | 72 | style 73 | 74 | ^$ 75 | 76 | 77 | 78 |
79 |
80 | 81 | 82 | 83 | .* 84 | 85 | ^$ 86 | 87 | 88 | BY_NAME 89 | 90 |
91 |
92 | 93 | 94 | 95 | .* 96 | 97 | http://schemas.android.com/apk/res/android 98 | 99 | 100 | ANDROID_ATTRIBUTE_ORDER 101 | 102 |
103 |
104 | 105 | 106 | 107 | .* 108 | 109 | .* 110 | 111 | 112 | BY_NAME 113 | 114 |
115 |
116 |
117 |
118 | 119 | 121 |
122 |
-------------------------------------------------------------------------------- /.idea/fileTemplates/TEA Feature.kt: -------------------------------------------------------------------------------- 1 | package ${PACKAGE_NAME} 2 | 3 | private typealias ReducerResult = Pair<${NAME}Feature.State, Set<${NAME}Feature.Eff>> 4 | 5 | object ${NAME}Feature { 6 | data class State() 7 | sealed class Msg 8 | sealed class Eff 9 | 10 | fun reduce(msg: Msg, state: State) = TODO() 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Mikhail Levchenko 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 | # Pu-erh 2 | 3 | ![Test and Lint on push](https://github.com/Mishkun/Puerh/workflows/Test%20and%20Lint%20on%20push/badge.svg) 4 | 5 | Here once would be a minimalistic, yet powerful state container, harnessing side-effects in the TEA-style! 6 | 7 | ## Sample 8 | 9 | To run translator sample app, you need an API key for IBM cloud translation. You can get it 10 | [here](https://cloud.ibm.com/docs/language-translator?topic=language-translator-gettingstarted#getting-started-tutorial) 11 | for free. 12 | 13 | Add this key to `local.properties` file like this, replacing `API_KEY` with yours: 14 | ```properties 15 | api_key="API_KEY" 16 | ``` 17 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.application") 3 | kotlin("android") 4 | kotlin("plugin.serialization") version "1.5.21" 5 | id("com.github.b3er.local.properties") version "1.1" 6 | } 7 | 8 | repositories { 9 | google() 10 | mavenCentral() 11 | } 12 | 13 | android { 14 | prepare() 15 | compileSdk = 31 16 | defaultConfig { 17 | applicationId = "io.github.mishkun.puerh.sampleapp" 18 | minSdk = 21 19 | } 20 | buildFeatures { 21 | compose = true 22 | } 23 | buildTypes.forEach { type -> 24 | type.buildConfigField("String", "API_KEY", project.properties["api_key"].toString()) 25 | } 26 | compileOptions { 27 | sourceCompatibility = JavaVersion.VERSION_1_8 28 | targetCompatibility = JavaVersion.VERSION_1_8 29 | } 30 | kotlinOptions { 31 | jvmTarget = "1.8" 32 | } 33 | 34 | composeOptions { 35 | kotlinCompilerExtensionVersion = "1.0.1" 36 | } 37 | } 38 | 39 | dependencies { 40 | implementation(project(":puerh-core")) 41 | implementation(project(":puerh-jvm-executors")) 42 | implementation(kotlin("stdlib")) 43 | implementation("androidx.appcompat:appcompat:1.3.1") 44 | 45 | implementation("androidx.activity:activity-ktx:1.4.0") 46 | implementation("androidx.core:core-ktx:1.7.0") 47 | implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.0") 48 | implementation("com.github.kittinunf.fuel:fuel:2.3.1") 49 | 50 | // Integration with activities 51 | implementation("androidx.activity:activity-compose:1.4.0") 52 | // Compose Material Design 53 | implementation("androidx.compose.material:material:1.0.5") 54 | // Animations 55 | implementation("androidx.compose.animation:animation:1.0.5") 56 | // Tooling support (Previews, etc.) 57 | implementation("androidx.compose.ui:ui-tooling:1.0.5") 58 | // When using a AppCompat theme 59 | implementation("com.google.accompanist:accompanist-appcompat-theme:0.16.0") 60 | // UI Tests 61 | androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.0.5") 62 | } 63 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle.kts. 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 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/src/main/kotlin/io/github/mishkun/puerh/sampleapp/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.puerh.sampleapp 2 | 3 | import android.os.Bundle 4 | import androidx.activity.compose.setContent 5 | import androidx.appcompat.app.AppCompatActivity 6 | import androidx.compose.runtime.* 7 | import com.google.accompanist.appcompattheme.AppCompatTheme 8 | import io.github.mishkun.puerh.core.Feature 9 | import io.github.mishkun.puerh.sampleapp.toplevel.logic.TopLevelFeature 10 | import io.github.mishkun.puerh.sampleapp.toplevel.ui.TopLevelScreen 11 | 12 | class MainActivity : AppCompatActivity() { 13 | private val feature 14 | get() = (application as MyApplication).feature 15 | 16 | override fun onCreate(savedInstanceState: Bundle?) { 17 | super.onCreate(savedInstanceState) 18 | setContent { 19 | AppCompatTheme { 20 | feature.Application { state, listener -> TopLevelScreen(state, listener) } 21 | } 22 | } 23 | feature.listenEffect(this::handleEffect) 24 | } 25 | 26 | private fun handleEffect(eff: TopLevelFeature.Eff) = when (eff) { 27 | TopLevelFeature.Eff.Finish -> onBackPressedDispatcher.onBackPressed() 28 | else -> Unit 29 | } 30 | 31 | override fun onBackPressed() { 32 | feature.accept(TopLevelFeature.Msg.OnBack) 33 | } 34 | } 35 | 36 | @Composable 37 | fun Feature.Application(composable: @Composable (model: Model, msgSink: (Msg) -> Unit) -> Unit) { 38 | composable(asComposeState().value, ::accept) 39 | } 40 | 41 | @Composable 42 | fun Feature<*, T, *>.asComposeState(): State { 43 | val state = currentComposer.cache(false) { mutableStateOf(currentState) } 44 | DisposableEffect(this) { 45 | val cancelable = listenState { state.value = it } 46 | onDispose { cancelable.cancel() } 47 | } 48 | return state 49 | } 50 | -------------------------------------------------------------------------------- /app/src/main/kotlin/io/github/mishkun/puerh/sampleapp/MyApplication.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.puerh.sampleapp 2 | 3 | import android.app.Application 4 | import io.github.mishkun.puerh.sampleapp.di.provideFeature 5 | 6 | class MyApplication : Application() { 7 | val feature by lazy { provideFeature(applicationContext) } 8 | } 9 | -------------------------------------------------------------------------------- /app/src/main/kotlin/io/github/mishkun/puerh/sampleapp/backstack/logic/BackstackFeature.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.puerh.sampleapp.backstack.logic 2 | 3 | import io.github.mishkun.puerh.sampleapp.backstack.logic.BackstackFeature.State.ScreenState 4 | 5 | private typealias ReducerResult = Pair> 6 | 7 | typealias NavGraph = Map> 8 | 9 | object BackstackFeature { 10 | fun initialState( 11 | startScreenName: String, 12 | graph: NavGraph 13 | ): State = State( 14 | backStack = emptyList(), 15 | graph = graph, 16 | screen = ScreenState(name = startScreenName, counter = 0) 17 | ) 18 | 19 | fun initialEffects(): Set = emptySet() 20 | 21 | data class State( 22 | val backStack: List, 23 | val graph: NavGraph, 24 | val screen: ScreenState 25 | ) { 26 | val previousScreen: ScreenState? = backStack.lastOrNull() 27 | val canGoTo: Pair get() = graph[screen.name] ?: graph.entries.first().value 28 | fun beenIn(screenName: String): Boolean = backStack.any { it.name == screenName } 29 | data class ScreenState(val name: String, val counter: Int) 30 | } 31 | 32 | sealed class Msg { 33 | data class OnGoToClicked(val screenName: String) : Msg() 34 | object OnIncreasePreviousClicked : Msg() 35 | object OnDecreasePreviousClicked : Msg() 36 | object OnIncreaseClicked : Msg() 37 | object OnDecreaseClicked : Msg() 38 | object OnBack : Msg() 39 | } 40 | 41 | sealed class Eff { 42 | object Finish : Eff() 43 | } 44 | 45 | fun reducer(msg: Msg, state: State): ReducerResult = when (msg) { 46 | is Msg.OnGoToClicked -> goToReducer(msg.screenName, state) 47 | Msg.OnIncreaseClicked -> state.changeCurrentScreen { changeCounterBy(1) } to emptySet() 48 | Msg.OnDecreaseClicked -> state.changeCurrentScreen { changeCounterBy(-1) } to emptySet() 49 | Msg.OnIncreasePreviousClicked -> state.changePreviousScreen { changeCounterBy(1) } to emptySet() 50 | Msg.OnDecreasePreviousClicked -> state.changePreviousScreen { changeCounterBy(-1) } to emptySet() 51 | Msg.OnBack -> goBackReducer(state) 52 | } 53 | 54 | private fun goBackReducer(state: State): ReducerResult = when (state.backStack.size) { 55 | 0 -> state to setOf(Eff.Finish) 56 | else -> { 57 | val newBackStack = state.backStack.dropLast(1) 58 | state.copy(backStack = newBackStack, screen = state.backStack.last()) to emptySet() 59 | } 60 | } 61 | 62 | private fun goToReducer( 63 | screenName: String, 64 | state: State 65 | ): ReducerResult = when (val backScreen = state.backStack.find { it.name == screenName }) { 66 | null -> { 67 | val newScreen = ScreenState(name = screenName, counter = 0) 68 | val newBackStack = state.backStack + state.screen 69 | state.copy(backStack = newBackStack, screen = newScreen) to emptySet() 70 | } 71 | else -> { 72 | val newBackStack = state.backStack.takeWhile { it.name != screenName } 73 | state.copy(backStack = newBackStack, screen = backScreen) to emptySet() 74 | } 75 | } 76 | 77 | private fun State.changePreviousScreen(block: ScreenState.() -> ScreenState): State { 78 | return if (backStack.isNotEmpty()) { 79 | val newBackStack = backStack.dropLast(1) + backStack.last().block() 80 | copy(backStack = newBackStack) 81 | } else { 82 | this 83 | } 84 | } 85 | 86 | private fun State.changeCurrentScreen(block: ScreenState.() -> ScreenState): State = 87 | copy(screen = screen.block()) 88 | 89 | private fun ScreenState.changeCounterBy(byValue: Int): ScreenState = 90 | copy(counter = counter + byValue) 91 | } 92 | -------------------------------------------------------------------------------- /app/src/main/kotlin/io/github/mishkun/puerh/sampleapp/backstack/logic/ScreenNames.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.puerh.sampleapp.backstack.logic 2 | 3 | val SCREEN_NAMES = 4 | listOf( 5 | "fine", 6 | "shine", 7 | "pine", 8 | "mine", 9 | "shrine", 10 | "nine", 11 | "spine", 12 | "sign", 13 | "dine", 14 | "wine", 15 | "spline", 16 | "ruine", 17 | "sine" 18 | ) 19 | -------------------------------------------------------------------------------- /app/src/main/kotlin/io/github/mishkun/puerh/sampleapp/backstack/ui/BackstackScreen.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.puerh.sampleapp.backstack.ui 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material.Button 7 | import androidx.compose.material.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.unit.Dp 12 | import io.github.mishkun.puerh.sampleapp.backstack.logic.BackstackFeature 13 | 14 | @Composable 15 | fun BackstackScreen( 16 | state: BackstackFeature.State, 17 | listener: (BackstackFeature.Msg) -> Unit 18 | ) = Column(modifier = Modifier.padding(Dp(16f))) { 19 | Text(text = state.renderPath()) 20 | BackstackCounter( 21 | "Current Screen", 22 | state.screen.counter, 23 | { listener(BackstackFeature.Msg.OnIncreaseClicked) }, 24 | { listener(BackstackFeature.Msg.OnDecreaseClicked) } 25 | ) 26 | state.previousScreen?.counter?.let { 27 | BackstackCounter( 28 | "Previous Screen", 29 | it, 30 | { listener(BackstackFeature.Msg.OnIncreasePreviousClicked) }, 31 | { listener(BackstackFeature.Msg.OnDecreasePreviousClicked) } 32 | ) 33 | } 34 | 35 | Text("Where to go next?") 36 | val (next1, next2) = state.canGoTo 37 | Button(onClick = { listener(BackstackFeature.Msg.OnGoToClicked(next1)) }) { 38 | Text(state.goButtonText(next1)) 39 | } 40 | Button(onClick = { listener(BackstackFeature.Msg.OnGoToClicked(next2)) }) { 41 | Text(state.goButtonText(next2)) 42 | } 43 | } 44 | 45 | fun BackstackFeature.State.renderPath(): String { 46 | val path = (backStack + screen).joinToString(separator = "/") { it.name } 47 | return "You are here: $path" 48 | } 49 | 50 | private fun BackstackFeature.State.goButtonText(screenName: String) = 51 | if (beenIn(screenName)) "Back to $screenName" else "Go to $screenName" 52 | 53 | 54 | @Composable 55 | private fun BackstackCounter( 56 | label: String, 57 | counter: Int, 58 | onIncClick: () -> Unit, 59 | onDecClick: () -> Unit 60 | ) = Column(modifier = Modifier.padding(Dp(8f))) { 61 | Text(label) 62 | Row(verticalAlignment = Alignment.CenterVertically) { 63 | Button(onClick = { onIncClick() }) { 64 | Text("Inc") 65 | } 66 | Text(counter.toString()) 67 | Button(onClick = { onDecClick() }) { 68 | Text("Dec") 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /app/src/main/kotlin/io/github/mishkun/puerh/sampleapp/counter/data/RandomEffectHandler.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.puerh.sampleapp.counter.data 2 | 3 | import io.github.mishkun.puerh.sampleapp.counter.logic.CounterFeature 4 | import java.util.concurrent.ExecutorService 5 | import kotlin.random.Random 6 | 7 | fun ExecutorService.randomEffectInterpreter( 8 | eff: CounterFeature.Eff, 9 | listener: (CounterFeature.Msg) -> Unit 10 | ) = when (eff) { 11 | is CounterFeature.Eff.GenerateRandomCounterChange -> submit { 12 | for (i in 1..100) { 13 | Thread.sleep(10) 14 | listener(CounterFeature.Msg.OnProgressPublish(i)) 15 | } 16 | val value = Random.nextInt(100) - 50 17 | listener(CounterFeature.Msg.OnCounterChange(value)) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/kotlin/io/github/mishkun/puerh/sampleapp/counter/logic/CounterFeature.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.puerh.sampleapp.counter.logic 2 | 3 | typealias ReducerResult = Pair> 4 | 5 | object CounterFeature { 6 | fun initialState(): State = State(1, null) 7 | 8 | fun initialEffects(): Set = setOf(Eff.GenerateRandomCounterChange) 9 | 10 | data class State(val counter: Int, val progress: Int?) 11 | 12 | sealed class Msg { 13 | data class OnCounterChange(val value: Int) : Msg() 14 | data class OnProgressPublish(val progress: Int) : Msg() 15 | object OnRandomClick : Msg() 16 | object OnIncreaseClick : Msg() 17 | object OnDecreaseClick : Msg() 18 | } 19 | 20 | sealed class Eff { 21 | object GenerateRandomCounterChange : Eff() 22 | } 23 | 24 | fun reducer(msg: Msg, state: State): ReducerResult = when (msg) { 25 | is Msg.OnCounterChange -> state.addToCounter(msg.value) to emptySet() 26 | is Msg.OnRandomClick -> state to setOf(Eff.GenerateRandomCounterChange) 27 | is Msg.OnIncreaseClick -> state.addToCounter(1) to emptySet() 28 | is Msg.OnDecreaseClick -> state.addToCounter(-1) to emptySet() 29 | is Msg.OnProgressPublish -> when (msg.progress) { 30 | in 1..99 -> state.copy(progress = msg.progress) to emptySet() 31 | else -> state.copy(progress = null) to emptySet() 32 | } 33 | } 34 | 35 | private fun State.addToCounter(newValue: Int) = copy(counter = counter + newValue) 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/kotlin/io/github/mishkun/puerh/sampleapp/counter/ui/CounterScreen.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.puerh.sampleapp.counter.ui 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material.Button 5 | import androidx.compose.material.CircularProgressIndicator 6 | import androidx.compose.material.MaterialTheme 7 | import androidx.compose.material.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.unit.Dp 12 | import io.github.mishkun.puerh.sampleapp.counter.logic.CounterFeature 13 | 14 | @Composable 15 | fun CounterScreen( 16 | counterState: CounterFeature.State, 17 | listener: (CounterFeature.Msg) -> Unit 18 | ) { 19 | Column(modifier = Modifier.padding(Dp(16f))) { 20 | Row( 21 | verticalAlignment = Alignment.CenterVertically, 22 | horizontalArrangement = Arrangement.SpaceBetween, 23 | modifier = Modifier.fillMaxWidth() 24 | ) { 25 | Button(onClick = { listener(CounterFeature.Msg.OnCounterChange(1)) }) { 26 | Text("Increase") 27 | } 28 | Text(text = counterState.counter.toString()) 29 | Button(onClick = { 30 | listener(CounterFeature.Msg.OnCounterChange(-1)) 31 | }) { 32 | Text("Decrease") 33 | } 34 | } 35 | Spacer(modifier = Modifier.height(Dp(16f))) 36 | Row(verticalAlignment = Alignment.CenterVertically) { 37 | Button(onClick = { 38 | listener(CounterFeature.Msg.OnRandomClick) 39 | }) { 40 | if (counterState.progress != null) { 41 | CircularProgressIndicator( 42 | progress = counterState.progress / 100f, 43 | Modifier.size(Dp(16f)), 44 | color = MaterialTheme.colors.onPrimary 45 | ) 46 | } else { 47 | Text("Random") 48 | } 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/src/main/kotlin/io/github/mishkun/puerh/sampleapp/di/FeatureProvider.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.puerh.sampleapp.di 2 | 3 | import android.content.Context 4 | import android.os.Handler 5 | import android.os.Looper 6 | import io.github.mishkun.puerh.core.Feature 7 | import io.github.mishkun.puerh.core.SyncFeature 8 | import io.github.mishkun.puerh.core.adapt 9 | import io.github.mishkun.puerh.core.wrapWithEffectHandler 10 | import io.github.mishkun.puerh.handlers.executor.ExecutorEffectHandler 11 | import io.github.mishkun.puerh.handlers.executor.ExecutorEffectsInterpreter 12 | import io.github.mishkun.puerh.sampleapp.backstack.logic.BackstackFeature 13 | import io.github.mishkun.puerh.sampleapp.backstack.logic.NavGraph 14 | import io.github.mishkun.puerh.sampleapp.backstack.logic.SCREEN_NAMES 15 | import io.github.mishkun.puerh.sampleapp.counter.data.randomEffectInterpreter 16 | import io.github.mishkun.puerh.sampleapp.toplevel.logic.TopLevelFeature 17 | import io.github.mishkun.puerh.sampleapp.translate.data.TranslationApiEffectHandler 18 | import kotlinx.serialization.json.Json 19 | import java.util.concurrent.Executor 20 | import java.util.concurrent.Executors 21 | 22 | fun provideFeature(applicationContext: Context): Feature { 23 | val androidMainThreadExecutor = object : Executor { 24 | private val handler = Handler(Looper.getMainLooper()) 25 | override fun execute(command: Runnable) { 26 | handler.post(command) 27 | } 28 | } 29 | val ioPool = Executors.newCachedThreadPool() 30 | 31 | return SyncFeature( 32 | TopLevelFeature.initialState( 33 | backstackFeatureState = BackstackFeature.initialState( 34 | SCREEN_NAMES.first(), 35 | generateScreenGraph() 36 | ) 37 | ), 38 | TopLevelFeature::reducer 39 | ).wrapWithEffectHandler( 40 | ExecutorEffectHandler( 41 | adaptedRandomEffectInterpreter, 42 | androidMainThreadExecutor, 43 | ioPool 44 | ) 45 | ).wrapWithEffectHandler( 46 | TranslationApiEffectHandler( 47 | Json { 48 | ignoreUnknownKeys = true 49 | encodeDefaults = false 50 | }, 51 | androidMainThreadExecutor, 52 | ioPool 53 | ).adapt( 54 | effAdapter = { (it as? TopLevelFeature.Eff.TranslateEff)?.eff }, 55 | msgAdapter = { TopLevelFeature.Msg.TranslateMsg(it) } 56 | ) 57 | ) 58 | } 59 | 60 | private val adaptedRandomEffectInterpreter: ExecutorEffectsInterpreter = 61 | { eff, listener -> 62 | if (eff is TopLevelFeature.Eff.CounterEff) randomEffectInterpreter(eff.eff) { 63 | listener(TopLevelFeature.Msg.CounterMsg(it)) 64 | } 65 | } 66 | 67 | private fun generateScreenGraph(): NavGraph { 68 | val nameTriples = SCREEN_NAMES.shuffled().zipWithNext().zip(SCREEN_NAMES) 69 | return nameTriples.associate { (shuffled, name3) -> 70 | val (name1, name2) = shuffled 71 | name1 to (name2 to name3) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /app/src/main/kotlin/io/github/mishkun/puerh/sampleapp/toplevel/logic/TopLevelFeature.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.puerh.sampleapp.toplevel.logic 2 | 3 | import io.github.mishkun.puerh.sampleapp.backstack.logic.BackstackFeature 4 | import io.github.mishkun.puerh.sampleapp.counter.logic.CounterFeature 5 | import io.github.mishkun.puerh.sampleapp.toplevel.logic.TopLevelFeature.State.ScreenState.* 6 | import io.github.mishkun.puerh.sampleapp.translate.logic.TranslateFeature 7 | 8 | private typealias ReducerResult = Pair> 9 | 10 | object TopLevelFeature { 11 | fun initialState( 12 | backstackFeatureState: BackstackFeature.State 13 | ): State = State( 14 | screens = listOf( 15 | Counter(CounterFeature.initialState()), 16 | Backstack(backstackFeatureState), 17 | Translate(TranslateFeature.initialState()) 18 | ), 19 | currentScreenPos = 0 20 | ) 21 | 22 | fun initialEffects(): Set = 23 | CounterFeature.initialEffects().mapTo(HashSet(), Eff::CounterEff) 24 | 25 | data class State( 26 | val screens: List, 27 | val currentScreenPos: Int 28 | ) { 29 | val currentScreen = screens[currentScreenPos] 30 | fun changeCurrentScreen(block: T.() -> T): State { 31 | @Suppress("UNCHECKED_CAST") val newScreen = (currentScreen as? T)?.block() 32 | val newList = if (newScreen != null) 33 | screens.toMutableList().also { mutableScreens -> 34 | mutableScreens[currentScreenPos] = newScreen 35 | } else screens 36 | return copy(screens = newList) 37 | } 38 | 39 | sealed class ScreenState { 40 | data class Counter( 41 | val state: CounterFeature.State 42 | ) : ScreenState() 43 | 44 | data class Backstack( 45 | val state: BackstackFeature.State 46 | ) : ScreenState() 47 | 48 | data class Translate( 49 | val state: TranslateFeature.State 50 | ) : ScreenState() 51 | } 52 | } 53 | 54 | sealed class Msg { 55 | data class CounterMsg(val msg: CounterFeature.Msg) : Msg() 56 | data class BackstackMsg(val msg: BackstackFeature.Msg) : Msg() 57 | data class TranslateMsg(val msg: TranslateFeature.Msg) : Msg() 58 | object OnBackstackScreenSwitch : Msg() 59 | object OnTranslateScreenSwitch : Msg() 60 | object OnCounterScreenSwitch : Msg() 61 | object OnBack : Msg() 62 | } 63 | 64 | sealed class Eff { 65 | object Finish : Eff() 66 | data class CounterEff(val eff: CounterFeature.Eff) : Eff() 67 | data class BackstackEff(val eff: BackstackFeature.Eff) : Eff() 68 | data class TranslateEff(val eff: TranslateFeature.Eff) : Eff() 69 | } 70 | 71 | fun reducer(msg: Msg, state: State): ReducerResult = when (state.currentScreen) { 72 | is Counter -> when (msg) { 73 | is Msg.CounterMsg -> { 74 | reduceCounter(state.currentScreen, msg.msg, state) 75 | } 76 | is Msg.OnBack -> { 77 | state to setOf(Eff.Finish) 78 | } 79 | is Msg.OnBackstackScreenSwitch -> state.copy(currentScreenPos = 1) to emptySet() 80 | is Msg.OnTranslateScreenSwitch -> state.copy(currentScreenPos = 2) to emptySet() 81 | else -> state to emptySet() 82 | } 83 | is Backstack -> when (msg) { 84 | is Msg.BackstackMsg -> { 85 | reduceBackstack(state.currentScreen, msg.msg, state) 86 | } 87 | is Msg.OnBack -> { 88 | reduceBackstack(state.currentScreen, BackstackFeature.Msg.OnBack, state) 89 | } 90 | is Msg.OnCounterScreenSwitch -> state.copy(currentScreenPos = 0) to emptySet() 91 | is Msg.OnTranslateScreenSwitch -> state.copy(currentScreenPos = 2) to emptySet() 92 | else -> state to emptySet() 93 | } 94 | is Translate -> when (msg) { 95 | is Msg.TranslateMsg -> reduceTranslate(state.currentScreen, msg.msg, state) 96 | is Msg.OnBackstackScreenSwitch -> state.copy(currentScreenPos = 1) to emptySet() 97 | is Msg.OnCounterScreenSwitch -> state.copy(currentScreenPos = 0) to emptySet() 98 | else -> state to emptySet() 99 | } 100 | } 101 | 102 | private fun reduceBackstack( 103 | currentScreen: Backstack, 104 | msg: BackstackFeature.Msg, 105 | state: State 106 | ): ReducerResult { 107 | val (newScreenState, effs) = BackstackFeature.reducer(msg, currentScreen.state) 108 | val newEffs = effs.mapTo(HashSet(), ::toTopLevel) 109 | return state.changeCurrentScreen { copy(state = newScreenState) } to newEffs 110 | } 111 | 112 | private fun toTopLevel(eff: BackstackFeature.Eff): Eff = when (eff) { 113 | is BackstackFeature.Eff.Finish -> Eff.Finish 114 | } 115 | 116 | private fun reduceCounter( 117 | currentScreen: Counter, 118 | msg: CounterFeature.Msg, 119 | state: State 120 | ): ReducerResult { 121 | val (newScreenState, effs) = CounterFeature.reducer(msg, currentScreen.state) 122 | val newEffs = effs.mapTo(HashSet(), Eff::CounterEff) 123 | return state.changeCurrentScreen { copy(state = newScreenState) } to newEffs 124 | } 125 | 126 | private fun reduceTranslate( 127 | currentScreen: Translate, 128 | msg: TranslateFeature.Msg, 129 | state: State 130 | ): ReducerResult { 131 | val (newScreenState, effs) = TranslateFeature.reducer(msg, currentScreen.state) 132 | val newEffs = effs.mapTo(HashSet(), Eff::TranslateEff) 133 | return state.changeCurrentScreen { copy(state = newScreenState) } to newEffs 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /app/src/main/kotlin/io/github/mishkun/puerh/sampleapp/toplevel/ui/TopLevelScreen.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.puerh.sampleapp.toplevel.ui 2 | 3 | import androidx.compose.material.* 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.res.painterResource 6 | import io.github.mishkun.puerh.R 7 | import io.github.mishkun.puerh.sampleapp.backstack.ui.BackstackScreen 8 | import io.github.mishkun.puerh.sampleapp.counter.ui.CounterScreen 9 | import io.github.mishkun.puerh.sampleapp.toplevel.logic.TopLevelFeature 10 | import io.github.mishkun.puerh.sampleapp.toplevel.logic.TopLevelFeature.State.ScreenState 11 | import io.github.mishkun.puerh.sampleapp.translate.ui.TranslateScreen 12 | 13 | private val bottomItems = listOf( 14 | "Counter" to R.drawable.ic_baseline_counter_24, 15 | "Backstack" to R.drawable.ic_baseline_backstack_24, 16 | "Translate" to R.drawable.ic_baseline_translate_24 17 | ) 18 | 19 | @Composable 20 | fun TopLevelScreen( 21 | state: TopLevelFeature.State, 22 | listener: (TopLevelFeature.Msg) -> Unit 23 | ) = Scaffold( 24 | content = { 25 | when (val screen = state.currentScreen) { 26 | is ScreenState.Counter -> { 27 | CounterScreen( 28 | screen.state 29 | ) { listener(TopLevelFeature.Msg.CounterMsg(it)) } 30 | } 31 | is ScreenState.Backstack -> { 32 | BackstackScreen( 33 | screen.state 34 | ) { listener(TopLevelFeature.Msg.BackstackMsg(it)) } 35 | } 36 | is ScreenState.Translate -> { 37 | TranslateScreen( 38 | screen.state 39 | ) { listener(TopLevelFeature.Msg.TranslateMsg(it)) } 40 | } 41 | } 42 | }, 43 | bottomBar = { 44 | BottomNavigation { 45 | val selectedIndex = state.currentScreenPos 46 | bottomItems.forEachIndexed { index, (title, icon) -> 47 | BottomNavigationItem( 48 | icon = { Icon(painterResource(icon), contentDescription = null) }, 49 | label = { Text(title) }, 50 | selected = selectedIndex == index, 51 | onClick = { 52 | when (index) { 53 | 0 -> listener(TopLevelFeature.Msg.OnCounterScreenSwitch) 54 | 1 -> listener(TopLevelFeature.Msg.OnBackstackScreenSwitch) 55 | 2 -> listener(TopLevelFeature.Msg.OnTranslateScreenSwitch) 56 | } 57 | } 58 | ) 59 | } 60 | } 61 | } 62 | ) 63 | -------------------------------------------------------------------------------- /app/src/main/kotlin/io/github/mishkun/puerh/sampleapp/translate/data/TranslationApiEffectHandler.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.puerh.sampleapp.translate.data 2 | 3 | import com.github.kittinunf.fuel.core.extensions.authentication 4 | import com.github.kittinunf.fuel.core.extensions.jsonBody 5 | import com.github.kittinunf.fuel.httpPost 6 | import com.github.kittinunf.result.map 7 | import io.github.mishkun.puerh.BuildConfig 8 | import io.github.mishkun.puerh.core.EffectHandler 9 | import io.github.mishkun.puerh.sampleapp.translate.logic.ApiError 10 | import io.github.mishkun.puerh.sampleapp.translate.logic.Language 11 | import io.github.mishkun.puerh.sampleapp.translate.logic.TranslateFeature 12 | import io.github.mishkun.puerh.sampleapp.util.nullIfBlank 13 | import kotlinx.serialization.Serializable 14 | import kotlinx.serialization.json.Json 15 | import java.util.concurrent.Executor 16 | import java.util.concurrent.ExecutorService 17 | import java.util.concurrent.Future 18 | 19 | val BASE_URL = 20 | "https://api.eu-de.language-translator.watson.cloud.ibm.com/instances/5e44a6a3-37d2-4fc0-b71d-ac503e83608d/v3/translate?version=2018-05-01" 21 | 22 | @Serializable 23 | private data class TranslationApiRequest( 24 | val source: String? = null, 25 | val target: String?, 26 | val text: List 27 | ) 28 | 29 | @Serializable 30 | private data class TranslationApiResult( 31 | val translations: List, 32 | val detectedLanguage: String? = null 33 | ) { 34 | @Serializable 35 | data class TranslationResult(val translation: String) 36 | } 37 | 38 | class TranslationApiEffectHandler( 39 | private val json: Json, 40 | private val callerThreadExecutor: Executor, 41 | private val effectsExecutorService: ExecutorService 42 | ) : EffectHandler { 43 | private var listener: ((TranslateFeature.Msg) -> Unit)? = null 44 | private var translateApiFuture: Future<*>? = null 45 | 46 | override fun setListener(listener: (TranslateFeature.Msg) -> Unit) { 47 | this.listener = { msg -> callerThreadExecutor.execute { listener(msg) } } 48 | } 49 | 50 | override fun handleEffect(eff: TranslateFeature.Eff) { 51 | translateApiFuture?.cancel(true) 52 | translateApiFuture = when (eff) { 53 | is TranslateFeature.Eff.TranslateText -> eff.text.nullIfBlank()?.let { 54 | effectsExecutorService.submit { 55 | Thread.sleep(300) 56 | val result = BASE_URL.httpPost() 57 | .authentication() 58 | .basic(username = "apikey", password = BuildConfig.API_KEY) 59 | .jsonBody( 60 | json.encodeToString( 61 | TranslationApiRequest.serializer(), 62 | TranslationApiRequest( 63 | eff.languageCodeFrom, 64 | eff.languageCodeTo, 65 | listOf(eff.text) 66 | ) 67 | ) 68 | ) 69 | .responseString().let { (first, second, result) -> 70 | result.map { value -> 71 | json.decodeFromJsonElement( 72 | TranslationApiResult.serializer(), 73 | json.parseToJsonElement(value) 74 | ) 75 | } 76 | } 77 | val msg = result.fold(success = { res -> 78 | TranslateFeature.Msg.OnTranslationResult( 79 | res.translations.first().translation, 80 | detectedLanguage = res.detectedLanguage?.let { code -> 81 | Language.values().find { it.code == code } 82 | } 83 | ) 84 | }, failure = { fail -> 85 | TranslateFeature.Msg.OnTranslationError( 86 | ApiError( 87 | fail.response.statusCode, 88 | fail.message 89 | ) 90 | ) 91 | }) 92 | callerThreadExecutor.execute { listener?.invoke(msg) } 93 | } 94 | } 95 | } 96 | } 97 | 98 | override fun cancel() { 99 | effectsExecutorService.shutdownNow() 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /app/src/main/kotlin/io/github/mishkun/puerh/sampleapp/translate/logic/ApiError.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.puerh.sampleapp.translate.logic 2 | 3 | data class ApiError(val code: Int?, val message: String?) 4 | -------------------------------------------------------------------------------- /app/src/main/kotlin/io/github/mishkun/puerh/sampleapp/translate/logic/Language.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.puerh.sampleapp.translate.logic 2 | 3 | enum class Language(val code: String, val displayName: String) { 4 | RUSSIAN("ru", "Russian"), 5 | ENGLISH("en", "English"), 6 | } 7 | -------------------------------------------------------------------------------- /app/src/main/kotlin/io/github/mishkun/puerh/sampleapp/translate/logic/TranslateFeature.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.puerh.sampleapp.translate.logic 2 | 3 | import io.github.mishkun.puerh.sampleapp.translate.logic.TranslateFeature.State.SelectedLanguage 4 | 5 | private typealias ReducerResult = Pair> 6 | 7 | object TranslateFeature { 8 | 9 | fun initialEff() = emptySet() 10 | 11 | fun initialState() = State( 12 | inputText = "", 13 | translatedText = "", 14 | isLoading = false, 15 | errorMessage = null, 16 | languagesState = State.LanguagesState( 17 | availableLanguages = Language.values().toList(), 18 | selectedFrom = SelectedLanguage.Auto, 19 | selectedTo = Language.ENGLISH 20 | ) 21 | ) 22 | 23 | data class State( 24 | val inputText: String, 25 | val translatedText: String, 26 | val languagesState: LanguagesState, 27 | val errorMessage: String?, 28 | val isLoading: Boolean 29 | ) { 30 | data class LanguagesState( 31 | val availableLanguages: List, 32 | val selectedFrom: SelectedLanguage, 33 | val selectedTo: Language 34 | ) 35 | 36 | sealed class SelectedLanguage { 37 | object Auto : SelectedLanguage() 38 | data class Concrete(val language: Language) : SelectedLanguage() 39 | companion object { 40 | fun fromLanguage(language: Language?): SelectedLanguage = when (language) { 41 | null -> Auto 42 | else -> Concrete(language) 43 | } 44 | } 45 | } 46 | } 47 | 48 | sealed class Msg { 49 | data class OnTextInput(val input: String) : Msg() 50 | data class OnLanguageFromChange(val language: Language?) : Msg() 51 | data class OnLanguageToChange(val language: Language) : Msg() 52 | object OnLanguageSwapClick : Msg() 53 | 54 | data class OnTranslationResult( 55 | val translatedText: String, 56 | val detectedLanguage: Language? 57 | ) : Msg() 58 | data class OnTranslationError(val apiError: ApiError) : Msg() 59 | } 60 | 61 | sealed class Eff { 62 | data class TranslateText( 63 | val languageCodeFrom: String?, 64 | val languageCodeTo: String, 65 | val text: String 66 | ) : Eff() 67 | } 68 | 69 | fun reducer(msg: Msg, state: State): ReducerResult = when (msg) { 70 | is Msg.OnTextInput -> { 71 | val eff = state.languagesState.toTranslateTextEff(msg.input) 72 | val newState = state.copy(inputText = msg.input, isLoading = true) 73 | newState to setOf(eff) 74 | } 75 | is Msg.OnTranslationError -> { 76 | val newState = state.copy(isLoading = false, errorMessage = msg.apiError.message) 77 | newState to emptySet() 78 | } 79 | is Msg.OnTranslationResult -> { 80 | val newFromLanguage = when (val oldFrom = state.languagesState.selectedFrom) { 81 | is SelectedLanguage.Concrete -> oldFrom 82 | is SelectedLanguage.Auto -> SelectedLanguage.fromLanguage(msg.detectedLanguage) 83 | } 84 | val newState = state.copy( 85 | isLoading = false, 86 | translatedText = msg.translatedText, 87 | errorMessage = null, 88 | languagesState = state.languagesState.copy( 89 | selectedFrom = newFromLanguage 90 | ) 91 | ) 92 | newState to emptySet() 93 | } 94 | is Msg.OnLanguageFromChange -> { 95 | val newState = state.copy( 96 | isLoading = true, 97 | languagesState = state.languagesState.copy( 98 | selectedFrom = SelectedLanguage.fromLanguage(msg.language) 99 | ) 100 | ) 101 | newState to setOf(newState.languagesState.toTranslateTextEff(state.inputText)) 102 | } 103 | is Msg.OnLanguageToChange -> { 104 | val newState = state.copy( 105 | isLoading = true, 106 | languagesState = state.languagesState.copy( 107 | selectedTo = msg.language 108 | ) 109 | ) 110 | newState to setOf(newState.languagesState.toTranslateTextEff(state.inputText)) 111 | } 112 | is Msg.OnLanguageSwapClick -> when (state.languagesState.selectedFrom) { 113 | is SelectedLanguage.Concrete -> { 114 | val newState = state.copy( 115 | isLoading = true, 116 | inputText = state.translatedText, 117 | translatedText = state.inputText, 118 | languagesState = state.languagesState.copy( 119 | selectedFrom = SelectedLanguage.fromLanguage(state.languagesState.selectedTo), 120 | selectedTo = state.languagesState.selectedFrom.language 121 | ) 122 | ) 123 | newState to setOf(newState.languagesState.toTranslateTextEff(newState.inputText)) 124 | } 125 | else -> state to emptySet() 126 | } 127 | } 128 | 129 | 130 | private fun State.LanguagesState.toTranslateTextEff(text: String) = Eff.TranslateText( 131 | languageCodeFrom = when (selectedFrom) { 132 | is SelectedLanguage.Concrete -> selectedFrom.language.code 133 | else -> null 134 | }, 135 | languageCodeTo = selectedTo.code, 136 | text = text 137 | ) 138 | } 139 | -------------------------------------------------------------------------------- /app/src/main/kotlin/io/github/mishkun/puerh/sampleapp/translate/ui/TranslateScreen.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.puerh.sampleapp.translate.ui 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.material.Button 7 | import androidx.compose.material.Icon 8 | import androidx.compose.material.Text 9 | import androidx.compose.material.TextField 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.res.painterResource 12 | import io.github.mishkun.puerh.R 13 | import io.github.mishkun.puerh.sampleapp.translate.logic.TranslateFeature 14 | import io.github.mishkun.puerh.sampleapp.translate.logic.TranslateFeature.State.SelectedLanguage 15 | 16 | @Composable 17 | fun TranslateScreen( 18 | translateState: TranslateFeature.State, 19 | listener: (TranslateFeature.Msg) -> Unit 20 | ) = Column(verticalArrangement = Arrangement.Bottom) { 21 | if (translateState.errorMessage == null) { 22 | Text(translateState.translatedText) 23 | } else { 24 | Text(translateState.errorMessage) 25 | } 26 | TextField(value = translateState.inputText, onValueChange = { listener(TranslateFeature.Msg.OnTextInput(it)) } ) 27 | Row() { 28 | val languages = translateState.languagesState.availableLanguages.map { it.displayName } 29 | val selectedFrom = when (val lang = translateState.languagesState.selectedFrom) { 30 | is SelectedLanguage.Auto -> "Auto" 31 | is SelectedLanguage.Concrete -> lang.language.displayName 32 | } 33 | // languagesSpinner(selectedFrom, listOf("Auto") + languages) { item -> 34 | // val lang = translateState.languagesState.availableLanguages.find { 35 | // it.displayName == item 36 | // } 37 | // listener(TranslateFeature.Msg.OnLanguageFromChange(lang)) 38 | // } 39 | Button(onClick = { listener(TranslateFeature.Msg.OnLanguageSwapClick) }) { 40 | Icon(painterResource(id = R.drawable.ic_interchange_24), contentDescription = null) 41 | } 42 | val selectedTo = translateState.languagesState.selectedTo.displayName 43 | // languagesSpinner(selectedTo, languages) { item -> 44 | // val lang = translateState.languagesState.availableLanguages.find { 45 | // it.displayName == item 46 | // } 47 | // lang?.let { listener(TranslateFeature.Msg.OnLanguageToChange(it)) } 48 | // } 49 | } 50 | } 51 | 52 | //private fun languagesSpinner( 53 | // selected: String, 54 | // languages: List, 55 | // listener: (String) -> Unit 56 | //) { 57 | // appCompatSpinner { 58 | // init { v -> 59 | // v as Spinner 60 | // v.adapter = ArrayAdapter( 61 | // v.context, 62 | // android.R.layout.simple_spinner_item, 63 | // languages 64 | // ) 65 | // v.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { 66 | // override fun onItemSelected( 67 | // adapterView: AdapterView<*>, 68 | // view: View?, 69 | // i: Int, 70 | // l: Long 71 | // ) { 72 | // val item = adapterView.getItemAtPosition(i).toString() 73 | // listener(item) 74 | // } 75 | // 76 | // override fun onNothingSelected(p0: AdapterView<*>?) {} 77 | // } 78 | // } 79 | // selection(languages.indexOf(selected)) 80 | // } 81 | //} 82 | // 83 | -------------------------------------------------------------------------------- /app/src/main/kotlin/io/github/mishkun/puerh/sampleapp/util/CollectionUtils.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.puerh.sampleapp.util 2 | 3 | fun setOfNotNull(vararg values: T): Set = HashSet().apply { 4 | values.filterNotTo(this) { it != null } 5 | } 6 | 7 | fun String.nullIfBlank() = if (isBlank()) null else this 8 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_backstack_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_counter_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_translate_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_interchange_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/menu/bottom_navigation.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 12 | 13 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mishkun/Puerh/dfc67a33c2687304370976956e7dc95d8c7ee882/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mishkun/Puerh/dfc67a33c2687304370976956e7dc95d8c7ee882/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mishkun/Puerh/dfc67a33c2687304370976956e7dc95d8c7ee882/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mishkun/Puerh/dfc67a33c2687304370976956e7dc95d8c7ee882/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mishkun/Puerh/dfc67a33c2687304370976956e7dc95d8c7ee882/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mishkun/Puerh/dfc67a33c2687304370976956e7dc95d8c7ee882/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mishkun/Puerh/dfc67a33c2687304370976956e7dc95d8c7ee882/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mishkun/Puerh/dfc67a33c2687304370976956e7dc95d8c7ee882/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mishkun/Puerh/dfc67a33c2687304370976956e7dc95d8c7ee882/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mishkun/Puerh/dfc67a33c2687304370976956e7dc95d8c7ee882/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #008577 4 | #00574B 5 | #D81B60 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | puerh 3 | 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | 3 | buildscript { 4 | repositories { 5 | google() 6 | mavenCentral() 7 | maven(url = "https://plugins.gradle.org/m2/") 8 | } 9 | dependencies { 10 | classpath("com.android.tools.build:gradle:7.0.2") 11 | } 12 | } 13 | 14 | allprojects { 15 | repositories { 16 | google() 17 | mavenCentral() 18 | } 19 | } 20 | 21 | task("clean", type = Delete::class) { 22 | delete(rootProject.buildDir) 23 | } 24 | -------------------------------------------------------------------------------- /buildSrc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `kotlin-dsl` 3 | } 4 | 5 | repositories { 6 | mavenCentral() 7 | google() 8 | } 9 | 10 | dependencies { 11 | implementation("com.android.tools.build:gradle:7.0.2") 12 | implementation(kotlin("gradle-plugin","1.5.21")) 13 | } 14 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/Config.kt: -------------------------------------------------------------------------------- 1 | import com.android.build.gradle.BaseExtension 2 | import com.android.build.gradle.internal.dsl.TestOptions 3 | import org.gradle.kotlin.dsl.DependencyHandlerScope 4 | import org.gradle.kotlin.dsl.delegateClosureOf 5 | import org.gradle.kotlin.dsl.get 6 | 7 | fun BaseExtension.prepare() { 8 | compileSdkVersion(28) 9 | 10 | defaultConfig { 11 | minSdkVersion(15) 12 | targetSdkVersion(28) 13 | versionCode = 1 14 | versionName = "1.0" 15 | 16 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 17 | consumerProguardFiles("consumer-rules.pro") 18 | } 19 | 20 | buildTypes { 21 | getByName("release") { 22 | isMinifyEnabled = true 23 | proguardFiles( 24 | getDefaultProguardFile("proguard-android-optimize.txt"), 25 | "proguard-rules.pro" 26 | ) 27 | } 28 | } 29 | 30 | testOptions { 31 | unitTests.delegateClosureOf { 32 | isIncludeAndroidResources = true 33 | } 34 | } 35 | 36 | sourceSets["main"].java.srcDir("src/main/kotlin") 37 | } 38 | 39 | fun DependencyHandlerScope.kotest() { 40 | val latestRelease = "4.6.3" 41 | testImplementation("io.kotest:kotest-runner-junit5-jvm:$latestRelease") 42 | testImplementation("io.kotest:kotest-assertions-core-jvm:$latestRelease") 43 | testImplementation("io.kotest:kotest-property-jvm:$latestRelease") 44 | } 45 | 46 | fun DependencyHandlerScope.testImplementation(dependency: String) { 47 | add("testImplementation", dependency) 48 | } 49 | 50 | fun DependencyHandlerScope.implementation(dependency: String) { 51 | add("implementation", dependency) 52 | } 53 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx1536m 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app's APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official 22 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mishkun/Puerh/dfc67a33c2687304370976956e7dc95d8c7ee882/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sat Jul 18 12:59:18 MSK 2020 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /puerh-core/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /puerh-core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.gradle.api.tasks.testing.logging.TestLogEvent 2 | 3 | plugins { 4 | kotlin("jvm") 5 | } 6 | 7 | tasks.withType { 8 | useJUnitPlatform() 9 | testLogging { 10 | events(*TestLogEvent.values()) 11 | } 12 | } 13 | 14 | dependencies { 15 | implementation(kotlin("stdlib")) 16 | kotest() 17 | } 18 | 19 | -------------------------------------------------------------------------------- /puerh-core/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mishkun/Puerh/dfc67a33c2687304370976956e7dc95d8c7ee882/puerh-core/consumer-rules.pro -------------------------------------------------------------------------------- /puerh-core/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.kts. 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 | -------------------------------------------------------------------------------- /puerh-core/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | -------------------------------------------------------------------------------- /puerh-core/src/main/java/io/github/mishkun/puerh/core/Cancelable.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.puerh.core 2 | 3 | interface Cancelable { 4 | fun cancel() 5 | } 6 | -------------------------------------------------------------------------------- /puerh-core/src/main/java/io/github/mishkun/puerh/core/EffectHandler.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.puerh.core 2 | 3 | interface EffectHandler : Cancelable { 4 | fun setListener(listener: (Msg) -> Unit) 5 | fun handleEffect(eff: Eff) 6 | } 7 | 8 | fun EffectHandler.adapt( 9 | effAdapter: (Eff2) -> Eff1?, 10 | msgAdapter: (Msg1) -> Msg2? 11 | ): EffectHandler = object : EffectHandler { 12 | override fun setListener(listener: (Msg2) -> Unit) = 13 | setListener { msg: Msg1 -> msgAdapter(msg)?.let { listener(it) } } 14 | override fun handleEffect(eff: Eff2) { 15 | effAdapter(eff)?.let { handleEffect(it) } 16 | } 17 | override fun cancel() = this@adapt.cancel() 18 | } 19 | 20 | 21 | fun Feature.wrapWithEffectHandler( 22 | effectHandler: EffectHandler, 23 | initialEffects: Set = emptySet() 24 | ) = object : Feature by this { 25 | override fun cancel() { 26 | effectHandler.cancel() 27 | this@wrapWithEffectHandler.cancel() 28 | } 29 | }.apply { 30 | effectHandler.setListener { msg -> accept(msg) } 31 | listenEffect { eff -> 32 | effectHandler.handleEffect(eff) 33 | } 34 | initialEffects.forEach(effectHandler::handleEffect) 35 | } 36 | -------------------------------------------------------------------------------- /puerh-core/src/main/java/io/github/mishkun/puerh/core/Feature.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.puerh.core 2 | 3 | interface Feature : Cancelable { 4 | val currentState: Model 5 | fun accept(msg: Msg) 6 | fun listenState(listener: (model: Model) -> Unit): Cancelable 7 | fun listenEffect(listener: (eff: Eff) -> Unit): Cancelable 8 | } 9 | -------------------------------------------------------------------------------- /puerh-core/src/main/java/io/github/mishkun/puerh/core/SyncFeature.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.puerh.core 2 | 3 | import io.github.mishkun.puerh.core.utils.addListenerAndMakeCancelable 4 | import io.github.mishkun.puerh.core.utils.notifyAll 5 | 6 | class SyncFeature( 7 | initialState: Model, 8 | private val reducer: (Msg, Model) -> Pair> 9 | ) : Feature { 10 | override var currentState: Model = initialState 11 | private set 12 | 13 | private var isCanceled = false 14 | private val stateListeners = mutableListOf<(state: Model) -> Unit>() 15 | private val effListeners = mutableListOf<(eff: Eff) -> Unit>() 16 | 17 | override fun accept(msg: Msg) { 18 | if (isCanceled) return 19 | val (newState, commands) = reducer(msg, currentState) 20 | currentState = newState 21 | stateListeners.notifyAll(newState) 22 | commands.forEach { command -> 23 | effListeners.notifyAll(command) 24 | } 25 | } 26 | 27 | override fun listenState(listener: (state: Model) -> Unit): Cancelable { 28 | val cancelable = stateListeners.addListenerAndMakeCancelable(listener) 29 | listener(currentState) 30 | return cancelable 31 | } 32 | 33 | override fun listenEffect(listener: (eff: Eff) -> Unit): Cancelable = 34 | effListeners.addListenerAndMakeCancelable(listener) 35 | 36 | override fun cancel() { 37 | isCanceled = true 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /puerh-core/src/main/java/io/github/mishkun/puerh/core/utils/General.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.puerh.core.utils 2 | 3 | import io.github.mishkun.puerh.core.Cancelable 4 | 5 | internal fun List<(T) -> Unit>.notifyAll(msg: T) = forEach { listener -> listener.invoke(msg) } 6 | 7 | internal fun MutableList<(T) -> Unit>.addListenerAndMakeCancelable(listener: (T) -> Unit): Cancelable { 8 | add(listener) 9 | return object : Cancelable { 10 | override fun cancel() { 11 | remove(listener) 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /puerh-core/src/main/java/io/github/mishkun/puerh/handlers/sync/SyncEffectHandler.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.puerh.handlers.sync 2 | 3 | import io.github.mishkun.puerh.core.EffectHandler 4 | 5 | private typealias MsgListener = (Msg) -> Unit 6 | typealias SyncEffectInterpreter = (MsgListener).(Eff) -> Unit 7 | 8 | class SyncEffectHandler( 9 | private val effectInterpreter: SyncEffectInterpreter 10 | ) : EffectHandler { 11 | private var listener: MsgListener? = null 12 | 13 | override fun setListener(listener: MsgListener) { 14 | this.listener = listener 15 | } 16 | 17 | override fun handleEffect(eff: Eff) { 18 | val listener = listener ?: {} 19 | effectInterpreter.invoke(listener, eff) 20 | } 21 | 22 | override fun cancel() { 23 | listener = null 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /puerh-core/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | puerh-core 3 | 4 | -------------------------------------------------------------------------------- /puerh-core/src/test/java/io/github/mishkun/puerh/core/SyncFeatureSpec.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.puerh.core 2 | 3 | import io.kotest.core.spec.style.FreeSpec 4 | import io.kotest.matchers.collections.beEmpty 5 | import io.kotest.matchers.collections.containExactly 6 | import io.kotest.matchers.should 7 | import io.kotest.matchers.shouldBe 8 | 9 | class SyncFeatureSpec : FreeSpec({ 10 | "describe current state" - { 11 | "it should be initialized with state" { 12 | val testFeature = createTestFeature() 13 | testFeature.currentState shouldBe SyncTestFeature.State(0) 14 | } 15 | "it should update state on message" { 16 | val testFeature = createTestFeature() 17 | testFeature.accept(SyncTestFeature.Msg) 18 | testFeature.currentState shouldBe SyncTestFeature.State(1) 19 | } 20 | } 21 | "describe state subscription" - { 22 | "it should get first state on subscription" { 23 | val testFeature = createTestFeature() 24 | var gotState: SyncTestFeature.State? = null 25 | val subscriber: (SyncTestFeature.State) -> Unit = { gotState = it } 26 | 27 | testFeature.listenState(subscriber) 28 | 29 | gotState shouldBe SyncTestFeature.State(0) 30 | } 31 | "it should always get exactly one latest state on subscription" { 32 | val testFeature = createTestFeature() 33 | val states = mutableListOf() 34 | val subscriber: (SyncTestFeature.State) -> Unit = { states.add(it) } 35 | 36 | testFeature.accept(SyncTestFeature.Msg) 37 | testFeature.listenState(subscriber) 38 | 39 | states should containExactly(SyncTestFeature.State(1)) 40 | } 41 | "it should subscribe to state updates" { 42 | val testFeature = createTestFeature() 43 | val states = mutableListOf() 44 | val subscriber: (SyncTestFeature.State) -> Unit = { states.add(it) } 45 | testFeature.listenState(subscriber) 46 | 47 | testFeature.accept(SyncTestFeature.Msg) 48 | 49 | states should containExactly(SyncTestFeature.State(0), SyncTestFeature.State(1)) 50 | } 51 | } 52 | "describe effects subscription" - { 53 | "it should subscribe to effects" { 54 | val testFeature = createTestFeature() 55 | val effects = mutableListOf() 56 | val subscriber: (SyncTestFeature.Eff) -> Unit = { effects.add(it) } 57 | testFeature.listenEffect(subscriber) 58 | 59 | testFeature.accept(SyncTestFeature.Msg) 60 | 61 | effects should containExactly(SyncTestFeature.Eff) 62 | } 63 | "it should not get any effects on subscription" { 64 | val testFeature = createTestFeature() 65 | val effects = mutableListOf() 66 | val subscriber: (SyncTestFeature.Eff) -> Unit = { effects.add(it) } 67 | 68 | testFeature.accept(SyncTestFeature.Msg) 69 | testFeature.listenEffect(subscriber) 70 | 71 | effects should beEmpty() 72 | } 73 | } 74 | "describe feature disposal" - { 75 | "it should not get any effects after calling cancel method" { 76 | val testFeature = createTestFeature() 77 | val effects = mutableListOf() 78 | val subscriber: (SyncTestFeature.Eff) -> Unit = { effects.add(it) } 79 | 80 | testFeature.listenEffect(subscriber) 81 | testFeature.cancel() 82 | testFeature.accept(SyncTestFeature.Msg) 83 | 84 | effects should beEmpty() 85 | } 86 | "it should not get any new states after calling cancel method" { 87 | val testFeature = createTestFeature() 88 | val states = mutableListOf() 89 | val subscriber: (SyncTestFeature.State) -> Unit = { states.add(it) } 90 | 91 | testFeature.listenState(subscriber) 92 | testFeature.cancel() 93 | testFeature.accept(SyncTestFeature.Msg) 94 | 95 | states should containExactly(SyncTestFeature.State(0)) 96 | } 97 | } 98 | }) 99 | 100 | private fun createTestFeature() = SyncFeature(SyncTestFeature.State(0), SyncTestFeature::reducer) 101 | 102 | private object SyncTestFeature { 103 | fun reducer(msg: Msg, state: State): Pair> = 104 | state.copy(counter = state.counter + 1) to setOf(Eff) 105 | 106 | object Msg 107 | object Eff 108 | data class State(val counter: Int) 109 | } 110 | -------------------------------------------------------------------------------- /puerh-core/src/test/java/io/github/mishkun/puerh/handlers/sync/SyncEffectHandlerSpec.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.puerh.handlers.sync 2 | 3 | import io.github.mishkun.puerh.core.SyncFeature 4 | import io.github.mishkun.puerh.core.wrapWithEffectHandler 5 | import io.kotest.core.spec.style.FreeSpec 6 | import io.kotest.matchers.shouldBe 7 | 8 | class SyncEffectHandlerSpec : FreeSpec({ 9 | "describe effect handler api" - { 10 | "should execute effect even if no listeners" { 11 | var executed = false 12 | val interpreter: ((Msg) -> Unit).(Eff) -> Unit = { executed = true } 13 | 14 | SyncEffectHandler(interpreter).handleEffect(Eff) 15 | 16 | executed shouldBe true 17 | } 18 | "should execute effect and notify listener with the resulting message" { 19 | var notified: Msg? = null 20 | val interpreter: ((Msg) -> Unit).(Eff) -> Unit = { invoke(Msg) } 21 | 22 | SyncEffectHandler(interpreter).apply { 23 | setListener { notified = it } 24 | handleEffect(Eff) 25 | } 26 | 27 | notified shouldBe Msg 28 | } 29 | "should not notify listeners if canceled" { 30 | var notified: Msg? = null 31 | val interpreter: ((Msg) -> Unit).(Eff) -> Unit = { invoke(Msg) } 32 | 33 | SyncEffectHandler(interpreter).apply { 34 | setListener { notified = it } 35 | cancel() 36 | handleEffect(Eff) 37 | } 38 | 39 | notified shouldBe null 40 | } 41 | } 42 | "describe integration with Feature" - { 43 | "should execute initial effects" { 44 | val initialEffects = setOf(Eff) 45 | val interpreter: ((Msg) -> Unit).(Eff) -> Unit = { invoke(Msg) } 46 | 47 | val feature = SyncFeature(State(0), ::justIncrementReducer) 48 | .wrapWithEffectHandler(SyncEffectHandler(interpreter), initialEffects) 49 | 50 | feature.currentState.counter shouldBe 1 51 | } 52 | "should get effects from feature after initial effects fired" { 53 | val initialEffects = setOf(Eff) 54 | val interpreter: ((Msg) -> Unit).(Eff) -> Unit = { invoke(Msg) } 55 | 56 | val feature = SyncFeature(State(0), ::incrementUntil5Reducer) 57 | .wrapWithEffectHandler(SyncEffectHandler(interpreter), initialEffects) 58 | 59 | feature.currentState.counter shouldBe 5 60 | } 61 | "should get effects from feature after external message acceptance" { 62 | val initialEffects = emptySet() 63 | val interpreter: ((Msg) -> Unit).(Eff) -> Unit = { invoke(Msg) } 64 | 65 | val feature = SyncFeature(State(0), ::incrementUntil5Reducer) 66 | .wrapWithEffectHandler(SyncEffectHandler(interpreter), initialEffects) 67 | feature.accept(Msg) 68 | 69 | feature.currentState.counter shouldBe 5 70 | } 71 | "should not notify feature when canceled" { 72 | val initialEffects = emptySet() 73 | val interpreter: ((Msg) -> Unit).(Eff) -> Unit = { invoke(Msg) } 74 | 75 | val feature = SyncFeature(State(0), ::incrementUntil5Reducer) 76 | .wrapWithEffectHandler(SyncEffectHandler(interpreter), initialEffects) 77 | feature.cancel() 78 | feature.accept(Msg) 79 | 80 | feature.currentState.counter shouldBe 0 81 | } 82 | } 83 | }) { 84 | object Msg 85 | object Eff 86 | data class State(val counter: Int) 87 | } 88 | 89 | private fun justIncrementReducer( 90 | msg: SyncEffectHandlerSpec.Msg, 91 | state: SyncEffectHandlerSpec.State 92 | ) = state.copy(counter = state.counter + 1) to emptySet() 93 | 94 | 95 | private fun incrementUntil5Reducer( 96 | msg: SyncEffectHandlerSpec.Msg, 97 | state: SyncEffectHandlerSpec.State 98 | ) = state.copy(counter = state.counter + 1) to 99 | if (state.counter < 4) setOf(SyncEffectHandlerSpec.Eff) else emptySet() 100 | 101 | -------------------------------------------------------------------------------- /puerh-jvm-executors/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /puerh-jvm-executors/README.md: -------------------------------------------------------------------------------- 1 | # JVM Executors module 2 | 3 | This module is a pure jvm module that contains helper class for building an EffectHandler 4 | implementation using just plain ExecutorService. This can help you if you want go complete bare-bones 5 | regarding your async work. 6 | 7 | ### Tip for Android developers: using Handler as an executor 8 | 9 | When working with android development, you might want to use Handler attached to MainLooper as your 10 | `callerThreadExecutor`. This can be easily accomplished with the snippet below. 11 | 12 | ```kotlin 13 | class AndroidMainThreadExecutor : Executor { 14 | private val handler = Handler(Looper.getMainLooper()) 15 | override fun execute(command: Runnable?) { 16 | handler.post(command) 17 | } 18 | } 19 | ``` 20 | -------------------------------------------------------------------------------- /puerh-jvm-executors/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.gradle.api.tasks.testing.logging.TestLogEvent 2 | 3 | plugins { 4 | kotlin("jvm") 5 | } 6 | 7 | repositories { 8 | mavenCentral() 9 | } 10 | 11 | tasks.withType { 12 | useJUnitPlatform() 13 | testLogging { 14 | events(*TestLogEvent.values()) 15 | } 16 | } 17 | 18 | dependencies { 19 | implementation(kotlin("stdlib")) 20 | implementation(project(":puerh-core")) 21 | kotest() 22 | } 23 | -------------------------------------------------------------------------------- /puerh-jvm-executors/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mishkun/Puerh/dfc67a33c2687304370976956e7dc95d8c7ee882/puerh-jvm-executors/consumer-rules.pro -------------------------------------------------------------------------------- /puerh-jvm-executors/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.kts.kts. 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 | -------------------------------------------------------------------------------- /puerh-jvm-executors/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | -------------------------------------------------------------------------------- /puerh-jvm-executors/src/main/java/io/github/mishkun/puerh/handlers/executor/ExecutorEffectHandler.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.puerh.handlers.executor 2 | 3 | import io.github.mishkun.puerh.core.EffectHandler 4 | import java.util.concurrent.Executor 5 | import java.util.concurrent.ExecutorService 6 | 7 | typealias ExecutorEffectsInterpreter = ExecutorService.(eff: Eff, listener: (Msg) -> Unit) -> Unit 8 | 9 | class ExecutorEffectHandler( 10 | private val effectsInterpreter: ExecutorEffectsInterpreter, 11 | private val callerThreadExecutor: Executor, 12 | private val effectsExecutorService: ExecutorService 13 | ) : EffectHandler { 14 | private var listener: ((Msg) -> Unit)? = null 15 | 16 | override fun setListener(listener: (Msg) -> Unit) { 17 | this.listener = { msg -> callerThreadExecutor.execute { listener(msg) } } 18 | } 19 | 20 | override fun handleEffect(eff: Eff) { 21 | effectsExecutorService.run { 22 | effectsInterpreter(eff, listener ?: {}) 23 | } 24 | } 25 | 26 | override fun cancel() { 27 | effectsExecutorService.shutdownNow() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /puerh-jvm-executors/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | puerh-executors 3 | 4 | -------------------------------------------------------------------------------- /puerh-jvm-executors/src/test/java/io/github/mishkun/puerh/handlers/ExecutorEffectHandlerSpec.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.puerh.handlers 2 | 3 | import io.github.mishkun.puerh.core.SyncFeature 4 | import io.github.mishkun.puerh.core.wrapWithEffectHandler 5 | import io.github.mishkun.puerh.handlers.executor.ExecutorEffectHandler 6 | import io.kotest.core.spec.style.FreeSpec 7 | import io.kotest.matchers.shouldBe 8 | import io.kotest.matchers.shouldNotBe 9 | import java.util.concurrent.CountDownLatch 10 | import java.util.concurrent.ExecutorService 11 | import java.util.concurrent.Executors 12 | 13 | class ExecutorEffectHandlerSpec : FreeSpec({ 14 | "describe executor handler api" - { 15 | "should execute effects off the caller thread" { 16 | val latch = CountDownLatch(1) 17 | var executingThreadId: Long? = null 18 | val interpreter: ExecutorService.(eff: Eff, listener: (Msg) -> Unit) -> Unit = 19 | { eff, listener -> 20 | submit { 21 | executingThreadId = Thread.currentThread().id 22 | listener.invoke(Msg.Msg2) 23 | latch.countDown() 24 | } 25 | } 26 | val handler = ExecutorEffectHandler( 27 | effectsInterpreter = interpreter, 28 | callerThreadExecutor = Executors.newSingleThreadExecutor(), 29 | effectsExecutorService = Executors.newSingleThreadExecutor() 30 | ) 31 | 32 | handler.handleEffect(Eff.Eff1) 33 | latch.await() 34 | 35 | executingThreadId shouldNotBe null 36 | executingThreadId shouldNotBe Thread.currentThread().id 37 | } 38 | "should call listener on callerThreadExecutor" { 39 | val latch = CountDownLatch(2) 40 | var executorThreadId: Long? = null 41 | var listenerThreadId: Long? = null 42 | val interpreter: ExecutorService.(eff: Eff, listener: (Msg) -> Unit) -> Unit = 43 | { eff, listener -> 44 | submit { 45 | listener.invoke(Msg.Msg2) 46 | latch.countDown() 47 | } 48 | } 49 | val callerThreadExecutor = Executors.newSingleThreadExecutor() 50 | callerThreadExecutor.execute { 51 | executorThreadId = Thread.currentThread().id 52 | } 53 | val handler = ExecutorEffectHandler( 54 | effectsInterpreter = interpreter, 55 | callerThreadExecutor = callerThreadExecutor, 56 | effectsExecutorService = Executors.newSingleThreadExecutor() 57 | ) 58 | handler.setListener { 59 | listenerThreadId = Thread.currentThread().id 60 | latch.countDown() 61 | } 62 | 63 | handler.handleEffect(Eff.Eff1) 64 | latch.await() 65 | 66 | listenerThreadId shouldNotBe null 67 | listenerThreadId shouldBe executorThreadId 68 | } 69 | } 70 | "describe executor handler feature integration" - { 71 | "should execute effects off thread and return messages to the feature" { 72 | val latch = CountDownLatch(3) 73 | val interpreter: ExecutorService.(eff: Eff, listener: (Msg) -> Unit) -> Unit = 74 | { eff, listener -> 75 | if (eff is Eff.Eff1) { 76 | submit { 77 | Thread.sleep(100) 78 | listener.invoke(Msg.Msg2) 79 | } 80 | } else { 81 | submit { 82 | Thread.sleep(100) 83 | listener.invoke(Msg.Msg1) 84 | } 85 | } 86 | } 87 | val feature = SyncFeature(initialState(), ::reduce).wrapWithEffectHandler( 88 | ExecutorEffectHandler( 89 | effectsInterpreter = interpreter, 90 | // this would be the MainLooper Handler in case of Android dev 91 | callerThreadExecutor = Executors.newSingleThreadExecutor(), 92 | effectsExecutorService = Executors.newCachedThreadPool() 93 | ) 94 | ) 95 | feature.listenState { latch.countDown() } 96 | 97 | feature.accept(Msg.Msg1) 98 | latch.await() 99 | 100 | feature.currentState.counter shouldBe 2 101 | feature.currentState.msg1Counter shouldBe 1 102 | feature.currentState.msg2Counter shouldBe 1 103 | } 104 | } 105 | }) { 106 | sealed class Eff { 107 | object Eff1 : Eff() 108 | object Eff2 : Eff() 109 | } 110 | 111 | sealed class Msg { 112 | object Msg1 : Msg() 113 | object Msg2 : Msg() 114 | } 115 | 116 | data class State(val msg1Counter: Int, val msg2Counter: Int, val counter: Int) 117 | companion object { 118 | fun initialState() = State(0, 0, 0) 119 | fun reduce(msg: Msg, state: State): Pair> = when (msg) { 120 | is Msg.Msg1 -> state.copy( 121 | msg1Counter = state.msg1Counter + 1, 122 | counter = state.counter + 1 123 | ) to setOf(Eff.Eff1) 124 | is Msg.Msg2 -> state.copy( 125 | msg2Counter = state.msg2Counter + 1, 126 | counter = state.counter + 1 127 | ) to emptySet() 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app', ':puerh-core', ':puerh-jvm-executors' 2 | rootProject.name='puerh' 3 | --------------------------------------------------------------------------------