Skip to content

Commit ce84bb5

Browse files
committed
ReactiveViewModel and ContextualVal and v6 API changes
1 parent 191b096 commit ce84bb5

File tree

86 files changed

+1647
-2536
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

86 files changed

+1647
-2536
lines changed

.editorconfig

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,5 @@ ktlint_standard_parameter-list-wrapping = disabled
3333
ktlint_standard_property-naming = disabled
3434
ktlint_standard_statement-wrapping = disabled
3535
ktlint_standard_string-template-indent = disabled
36+
ktlint_standard_class-signature = disabled
37+
ktlint_standard_function-expression-body = disabled

.github/workflows/build.yaml

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,17 +59,23 @@ jobs:
5959

6060
- name: Bundle the build report
6161
if: failure()
62-
run: find . -type d -name 'reports' | zip -@ -r build-reports.zip
62+
run: find . -type d -name 'reports' | zip -@ -r build-report.zip
6363
- name: Upload the build report
6464
if: failure()
6565
uses: actions/upload-artifact@master
6666
with:
67-
name: error-report
68-
path: build-reports.zip
67+
name: build-report
68+
path: build-report.zip
6969

7070
docs:
7171
name: Publish docs
7272
runs-on: ubuntu-latest
73+
environment:
74+
name: github-pages
75+
permissions:
76+
contents: read
77+
pages: write
78+
id-token: write
7379
if: startsWith(github.ref, 'refs/heads/_publish') || startsWith(github.ref, 'refs/tags/v0') || startsWith(github.ref, 'refs/tags/v1') || startsWith(github.ref, 'refs/tags/v2') || startsWith(github.ref, 'refs/tags/v3') || startsWith(github.ref, 'refs/tags/v4') || startsWith(github.ref, 'refs/tags/v5') || startsWith(github.ref, 'refs/tags/v6') || startsWith(github.ref, 'refs/tags/v7') || startsWith(github.ref, 'refs/tags/v8') || startsWith(github.ref, 'refs/tags/v9')
7480
steps:
7581
- name: Checkout
@@ -81,13 +87,16 @@ jobs:
8187
distribution: "temurin"
8288
cache: "gradle"
8389
check-latest: true
84-
- name: Build project
85-
run: ./gradlew assemble --stacktrace
8690
- name: Install common deps
8791
run: sudo scripts/build-common.sh
88-
- name: Install Python deps
89-
run: poetry install
90-
- name: Deploy docs
91-
run: ./deploy-docs.sh
92-
env:
93-
GH_PAGES_DEPLOY_KEY: ${{ secrets.GH_PAGES_DEPLOY_KEY }}
92+
- name: Generate docs
93+
run: ./gradlew dokkaGenerate
94+
- name: Setup Pages
95+
uses: actions/configure-pages@v5
96+
- name: Upload Artifact
97+
uses: actions/upload-pages-artifact@v3
98+
with:
99+
path: 'build/docs/html'
100+
- name: Deploy to GitHub Pages
101+
id: deployment
102+
uses: actions/deploy-pages@v4

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Changelog
22

3+
## Next release (6.0.0 preview)
4+
5+
* Added `ContextualVal`/`ContextualValSuspend` for coroutine-local variables (like thread-locals or `CompositionLocal` for coroutines).
6+
* Made the experimental `ReactiveViewModel` more flexible by building it around `ContextualVal`.
7+
* Changed `loading` and all `withLoading` parameters from `MutableValueFlow` to `MutableStateFlow`.
8+
* Changed `increment()`/`decrement()` and `replace` to work on `MutableStateFlow` instead of `MutableValueFlow`.
9+
* Added `MutableStateFlow.replaceAndGet` and `getAndReplace` which are like their similarly named `updateAndGet`/`getAndUpdate` counterparts, but with the value passed as `this`.
10+
* Changed `StateFlowStore` to return `MutableStateFlow` instead of `MutableValueFlow`.
11+
* Removed `ValueFlow`, `MutableValueFlow` and `SuspendMutableValueFlow`.
12+
* Renamed `launcherScope` to `scope` on `CoroutineLauncher` and all subtypes like `BaseReactiveState`.
13+
* Renamed modules: `reactivestate` -> `reactivestate-android`, `reactivestate-test` -> `reactivestate-android-test`. You might only want to use `reactivestate-compose` in case you don't need to add ViewModels to Activities/Fragments.
14+
315
## 5.13.0
416

517
* Added `OnReactiveStateAttachedTo.onReactiveStateAttachedTo(parent)`, so the ViewModel can finish its initialization and do additional checks against the parent (which can be the UI or a parent ViewModel).

NOTICE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
ReactiveState-Kotlin library.
2-
Copyright 2020-2024 Ensody GmbH, Waldemar Kornewald
2+
Copyright 2020-2025 Ensody GmbH, Waldemar Kornewald

README.md

Lines changed: 67 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -2,142 +2,113 @@
22

33
[![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.ensody.reactivestate/reactivestate/badge.svg?gav=true)](https://maven-badges.herokuapp.com/maven-central/com.ensody.reactivestate/reactivestate?gav=true)
44

5-
Easy reactive state management and ViewModels for Kotlin Multiplatform. No boilerplate. Compatible with Android.
5+
Easy reactive state management and ViewModels for Kotlin Multiplatform.
66

7-
ReactiveState-Kotlin provides these foundations for building multiplatform ViewModels and lower-level logic:
7+
See the [ReactiveState documentation](https://ensody.github.io/ReactiveState-Kotlin/reactivestate-core/) for more details.
88

9-
* [reactive programming](https://ensody.github.io/ReactiveState-Kotlin/reactive-programming/): everything is recomputed/updated automatically based on straightforward code
10-
* [demand-driven programming](https://ensody.github.io/ReactiveState-Kotlin/demand-driven-programming/): resource-consuming computations and values are allocated on-demand and disposed when not needed
11-
* [multiplatform](https://ensody.github.io/ReactiveState-Kotlin/multiplatform-viewmodels/): share your ViewModels and reactive state handling logic between all platforms
12-
* [event handling](https://ensody.github.io/ReactiveState-Kotlin/event-handling/): simple events based on interfaces (more composable and less boilerplate than sealed classes)
13-
* [automatic error catching](https://ensody.github.io/ReactiveState-Kotlin/error-handling/): no more forgotten try-catch or copy-pasted error handling logic all over the place
14-
* [coroutine-based unit tests](https://ensody.github.io/ReactiveState-Kotlin/unit-testing-coroutines/): worry no more about passing around `CoroutineDispatcher`s everywhere
15-
* [lifecycle handling](https://ensody.github.io/ReactiveState-Kotlin/lifecycle-handling/)
16-
* [state restoration](https://ensody.github.io/ReactiveState-Kotlin/state-restoration/)
9+
## Examples
1710

18-
See the [ReactiveState documentation](https://ensody.github.io/ReactiveState-Kotlin/) for more details.
11+
Map/transform a StateFlow:
1912

20-
## Supported platforms
21-
22-
android, jvm, ios, tvos, watchos, macosArm64, macosX64, mingwX64, linuxX64
23-
24-
## Installation
25-
26-
Add the package to your `build.gradle`'s `dependencies {}`:
27-
28-
```groovy
29-
dependencies {
30-
// Add the BOM using the desired ReactiveState version
31-
api platform("com.ensody.reactivestate:reactivestate-bom:VERSION")
13+
```kotlin
14+
val number = MutableStateFlow(0)
15+
val doubledNumber: StateFlow<Int> = derived { 2 * get(number) }
16+
```
3217

33-
// Leave out the version number from now on:
34-
implementation "com.ensody.reactivestate:reactivestate"
18+
Collect two StateFlows (just collect without transforming):
3519

36-
// Utils for unit tests that want to use coroutines
37-
implementation "com.ensody.reactivestate:reactivestate-test"
38-
// Note: kotlin-coroutines-test only supports the "jvm" target,
39-
// so reactivestate-test has the same limitation
20+
```kotlin
21+
val base = MutableStateFlow(0)
22+
val extra = MutableStateFlow(0)
23+
autoRun {
24+
if (get(base) + get(extra) > 10) {
25+
alert("You're flying too high")
26+
}
4027
}
4128
```
4229

43-
Also, make sure you've integrated the Maven Central repo, e.g. in your root `build.gradle`:
30+
Multiplatform ViewModels with automatic error handling and loading indicator tracking:
4431

45-
```groovy
46-
subprojects {
47-
repositories {
48-
// ...
49-
mavenCentral()
50-
// ...
32+
```kotlin
33+
class ExampleViewModel(scope: CoroutineScope, val repository: ExampleRepository) : ReactiveViewModel(scope) {
34+
val inputFieldValue = MutableStateFlow("default")
35+
36+
fun submit() {
37+
// The launch function automatically catches exceptions and increments/decrements the loading indicator.
38+
// This way you can't forget the fundamentals that have to be always handled correctly.
39+
launch {
40+
repository.submit(inputFieldValue.value)
41+
}
5142
}
5243
}
5344
```
5445

55-
## Quick intro
56-
57-
The following two principles are here to give you a quick idea of the reactive programming aspect only.
58-
The "Guide" section in the [documentation](https://ensody.github.io/ReactiveState-Kotlin/) describes how to work with the more advanced aspects like multiplatform ViewModels, lifecycle handling, etc.
59-
60-
Note: While the discussion is about `StateFlow`, you can also use `LiveData` or even implement extensions for other observable values.
61-
62-
### Observing StateFlow
63-
64-
Imagine you have an input form with first and last name and want to observe two `StateFlow` values at the same time:
65-
66-
* `isFirstNameValid: StateFlow<Boolean>`
67-
* `isLastNameValid: StateFlow<Boolean>`
68-
69-
This is how you'd do it by using the `autoRun` function:
46+
Intercept MutableStateFlow:
7047

7148
```kotlin
72-
autoRun {
73-
submitButton.isEnabled = get(isFirstNameValid) && get(isLastNameValid)
49+
public val state: MutableStateFlow<String> = MutableStateFlow("value").afterUpdate {
50+
// This is called every time after someone sets `state.value = ...`
7451
}
7552
```
7653

77-
With `get(isFirstNameValid)` you retrieve `isFirstNameValid.value` and at the same time tell `autoRun` to re-execute the block whenever the value is changed.
78-
That code is similar to writing this:
54+
Convert StateFlow to MutableStateFlow:
7955

8056
```kotlin
81-
lifecycleScope.launchWhenStarted {
82-
isFirstNameValid
83-
.combine(isLastNameValid) { firstNameValid, lastNameValid ->
84-
firstNameValid to lastNameValid
85-
}
86-
.conflate()
87-
.collect { (firstNameValid, lastNameValid) ->
88-
try {
89-
submitButton.isEnabled = firstNameValid && lastNameValid
90-
} catch (e: CancellationException) {
91-
throw e
92-
} catch (e: Throwable) {
93-
onError(e)
94-
}
95-
}
57+
val readOnly: StateFlow<Int> = getSomeStateFlow()
58+
val mutable: MutableStateFlow<Int> = readOnly.toMutable { value: Int ->
59+
// This is executed whenever someone sets `mutable.value = ...`.
9660
}
9761
```
9862

99-
### Reactive StateFlow / reactive data
63+
See the [ReactiveState documentation](https://ensody.github.io/ReactiveState-Kotlin/reactivestate-core/) for more details.
10064

101-
The same principle can be used to create a `derived`, reactive `StateFlow`:
65+
## Supported platforms
10266

103-
```kotlin
104-
val isFormValid: StateFlow<Boolean> = derived {
105-
get(isFirstNameValid) && get(isLastNameValid)
106-
}
107-
```
67+
android, jvm, ios, tvos, watchos, macosArm64, macosX64, mingwX64, linuxX64
10868

109-
Now you can use `autoRun { submitButton.isEnabled = get(isFormValid) }` in the rest of your code.
69+
## Installation
11070

111-
Going even further, `isFirstNameValid` itself would usually also be the result of a `derived` computation.
112-
So, you can have multiple layers of reactive `derived` `StateFlow`s.
71+
Add the package to your `build.gradle`'s `dependencies {}`:
11372

114-
## Relation to Jetpack Compose / Flutter / React
73+
```groovy
74+
dependencies {
75+
// Add the BOM using the desired ReactiveState version
76+
api platform("com.ensody.reactivestate:reactivestate-bom:VERSION")
77+
// Leave out the version number from now on.
11578
116-
Reactive UI frameworks like Jetpack Compose automatically rebuild the UI whenever e.g. a `StateFlow` changes.
117-
So, in the UI layer `autoRun` can usually be replaced with a `Composable`.
79+
// Jetpack Compose integration
80+
implementation "com.ensody.reactivestate:reactivestate-compose"
11881
119-
However, below the UI your data still needs to be reactive, too.
120-
Here ReactiveState provides `derived` to automatically recompute a `StateFlow` based on other `StateFlow`s.
121-
This pattern is very useful in practice and provides the perfect foundation for frameworks like Jetpack Compose which primarily focus on the UI aspect.
122-
ReactiveState's `derived` and `autoRun` provide the same reactivity for your data and business logic.
82+
// Android-only integration for Activity/Fragment
83+
implementation "com.ensody.reactivestate:reactivestate-android"
12384
124-
In Jetpack Compose you even have `derivedStateOf` which is very similar to `derived`.
125-
So, you can choose whether you want to build your business logic based on the official coroutines library (`StateFlow`/`derived`) or Jetpack Compose (`State`/`derivedStateOf`). However, the coroutines library has the advantage that it's available for more platforms and it's fully independent of any UI frameworks. Finally, most open-source non-UI libraries will probably be based on coroutines, so `StateFlow` based code might also be better for compatibility/interoperability.
85+
// UI-independent core APIs
86+
implementation "com.ensody.reactivestate:reactivestate-core"
12687
127-
In other words, the combination of both solutions used together results in a fully reactive, multiplatform codebase - which improves code simplicity and avoids many bugs.
88+
// Utils for unit tests that want to use coroutines
89+
implementation "com.ensody.reactivestate:reactivestate-core-test"
12890
129-
Moreover, Jetpack Compose currently doesn't provide any multiplatform ViewModel support or any large-scale architecture.
130-
So, this library solves that by providing `BaseReactiveState` for ViewModels.
131-
It also comes with a lifecycle-aware event system (`eventNotifier`) and loading state handling (so you can track one or multiple different loading indicators based on coroutines that you launch).
91+
// Android-only unit test extensions
92+
implementation "com.ensody.reactivestate:reactivestate-android-test"
93+
}
94+
```
13295

133-
## See also
96+
Also, make sure you've integrated the Maven Central repo, e.g. in your root `build.gradle`:
13497

135-
This library is based on [reactive_state](https://github.com/ensody/reactive_state) for Flutter and adapted to Kotlin Multiplatform and Android patterns.
98+
```groovy
99+
subprojects {
100+
repositories {
101+
// ...
102+
mavenCentral()
103+
// ...
104+
}
105+
}
106+
```
136107

137108
## License
138109

139110
```
140-
Copyright 2024 Ensody GmbH, Waldemar Kornewald
111+
Copyright 2025 Ensody GmbH, Waldemar Kornewald
141112
142113
Licensed under the Apache License, Version 2.0 (the "License");
143114
you may not use this file except in compliance with the License.

build.gradle

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform
22

33
buildscript {
4-
ext.kotlin_version = '2.0.0'
4+
ext.kotlin_version = '2.1.20'
55
ext.composeCompilerVersion = '1.6.11'
66
}
77

88
plugins {
9-
id 'com.android.application' version '8.6.1' apply false
10-
id 'org.jetbrains.kotlin.android' version "$kotlin_version" apply false
11-
id "org.jetbrains.compose" version "$composeCompilerVersion" apply false
12-
id "org.jetbrains.kotlin.plugin.compose" version "$kotlin_version" apply false
13-
id "org.jetbrains.dokka" version "1.9.20"
14-
id 'pl.allegro.tech.build.axion-release' version '1.18.13'
15-
id 'com.github.ben-manes.versions' version '0.51.0'
16-
id "io.github.gradle-nexus.publish-plugin" version "2.0.0"
9+
alias(libs.plugins.android.application) apply false
10+
alias(libs.plugins.kotlin.android) apply false
11+
alias(libs.plugins.jetbrains.compose) apply false
12+
alias(libs.plugins.kotlin.compose) apply false
13+
alias(libs.plugins.dokka)
14+
alias(libs.plugins.axionRelease)
15+
alias(libs.plugins.versions)
16+
alias(libs.plugins.nexusPublish)
1717
}
1818

1919
allprojects {
@@ -47,6 +47,7 @@ subprojects {
4747
isComposeProject = project.name.endsWith("-compose")
4848
kotlinCompilerArgs = [
4949
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
50+
"-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi",
5051
"-opt-in=kotlinx.coroutines.FlowPreview",
5152
"-opt-in=com.ensody.reactivestate.ExperimentalReactiveStateApi",
5253
]
@@ -160,9 +161,7 @@ subprojects {
160161
apply from: "$rootDir/gradle/common/ktlint.gradle"
161162

162163
if (isAndroidProject) {
163-
androidLibrary(
164-
minVersion: isComposeProject ? 21 : 19,
165-
)
164+
androidLibrary()
166165

167166
android {
168167
// Resolve build conflicts for test modules
@@ -180,6 +179,10 @@ subprojects {
180179
}
181180

182181
apply from: "$rootDir/gradle/common/dokka.gradle"
182+
setupDokka()
183+
rootProject.dependencies {
184+
dokka(project)
185+
}
183186

184187
// TODO: Switch to Kover
185188
// if (!publishing) {
@@ -214,8 +217,22 @@ subprojects {
214217
)
215218
}
216219

217-
tasks.named("dokkaGfmMultiModule").configure {
218-
outputDirectory.set(project.file("docs/reference"))
220+
apply from: "$rootDir/gradle/common/dokka.gradle"
221+
setupDokka()
222+
dependencies {
223+
dokka(project)
224+
}
225+
dokka {
226+
dokkaSourceSets {
227+
configureEach {
228+
includes.from("README.md")
229+
}
230+
}
231+
dokkaPublications {
232+
html {
233+
outputDirectory.set(project.file("build/docs/html"))
234+
}
235+
}
219236
}
220237

221238
nexusPublishing {

0 commit comments

Comments
 (0)