From b12b7d7ccea64fce57aeddc78e5904ecbe12ff14 Mon Sep 17 00:00:00 2001 From: flosch Date: Thu, 22 Jan 2026 21:57:11 +0100 Subject: [PATCH 1/3] Update dependencies --- .github/workflows/build.yml | 12 +++++----- .github/workflows/deploy-release.yml | 4 ++-- .github/workflows/deploy-snapshot.yml | 4 ++-- .java-version | 2 +- CHANGELOG.md | 6 +++++ README.md | 4 ++++ control-core/build.gradle.kts | 7 +++--- examples/android-counter/build.gradle.kts | 16 ++++++++++--- gradle/libs.versions.toml | 28 +++++++++++------------ gradle/wrapper/gradle-wrapper.properties | 2 +- 10 files changed, 52 insertions(+), 33 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b099541a..ff50d5db 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: 'adopt' - java-version: '17' + java-version-file: .java-version - name: "lint" run: ./gradlew lint ktlintCheck @@ -25,7 +25,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: 'adopt' - java-version: '17' + java-version-file: .java-version - name: "Test" run: ./gradlew test @@ -38,7 +38,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: 'adopt' - java-version: '17' + java-version-file: .java-version - name: "Check if coverage is satisfied" run: ./gradlew koverVerify @@ -51,7 +51,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: 'adopt' - java-version: '17' + java-version-file: .java-version - name: "Validates the Api" run: ./gradlew apiCheck @@ -65,7 +65,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: 'adopt' - java-version: '17' + java-version-file: .java-version - name: "Assemble" run: ./gradlew assemble @@ -79,7 +79,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: 'adopt' - java-version: '17' + java-version-file: .java-version - name: "Create coverage reports" run: ./gradlew koverXmlReport - name: "Upload coverage to codecov" diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml index 019e20cf..bc74e44f 100644 --- a/.github/workflows/deploy-release.yml +++ b/.github/workflows/deploy-release.yml @@ -15,7 +15,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: "adopt" - java-version: "17" + java-version-file: .java-version - name: "Checks all the things" run: ./gradlew lint ktlintCheck test koverVerify apiCheck assemble @@ -29,7 +29,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: "adopt" - java-version: "17" + java-version-file: .java-version - name: "Get tag and save into env" uses: olegtarasov/get-tag@v2.1 id: tagName diff --git a/.github/workflows/deploy-snapshot.yml b/.github/workflows/deploy-snapshot.yml index 76a3359f..78a51e30 100644 --- a/.github/workflows/deploy-snapshot.yml +++ b/.github/workflows/deploy-snapshot.yml @@ -15,7 +15,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: "adopt" - java-version: "17" + java-version-file: .java-version - name: "Checks all the things" run: ./gradlew lint ktlintCheck test koverVerify apiCheck assemble @@ -29,7 +29,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: "adopt" - java-version: "17" + java-version-file: .java-version - name: "Get tag and save into env" uses: olegtarasov/get-tag@v2.1 id: tagName diff --git a/.java-version b/.java-version index 03b6389f..98d9bcb7 100644 --- a/.java-version +++ b/.java-version @@ -1 +1 @@ -17.0 +17 diff --git a/CHANGELOG.md b/CHANGELOG.md index 30856857..e288960b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # changelog +## `[2.1.0]` - 2025-01-22 + +- Update Kotlin to `2.3.0` +- Update kotlinx.coroutines to `1.10.2` +- Add AI agent skill documentation (`.opencode/skills/control-library.md`) for comprehensive library usage guidance + ## `[2.0.0]` - 2025-03-16 - Update Kotlin to `2.1.10` diff --git a/README.md b/README.md index 67ff2950..cc2e4669 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,10 @@ unit tested. * [android-counter](examples/android-counter): android counter example built with [jetpack compose](https://developer.android.com/jetpack/compose). +## skills + +* [control-library](.opencode/skills/control-library.md): comprehensive guide for AI agents to implement state management using the control library. + ## author visit my [website](https://florianschuster.at/). diff --git a/control-core/build.gradle.kts b/control-core/build.gradle.kts index 25b02646..f0559110 100644 --- a/control-core/build.gradle.kts +++ b/control-core/build.gradle.kts @@ -1,4 +1,3 @@ -import com.vanniktech.maven.publish.SonatypeHost import kotlinx.kover.gradle.plugin.dsl.AggregationType import kotlinx.kover.gradle.plugin.dsl.CoverageUnit @@ -76,13 +75,13 @@ kover { // ---- publishing --- // group = "at.florianschuster.control" -version = System.getenv("libraryVersionTag") +version = System.getenv("libraryVersionTag") ?: "local" mavenPublishing { // Snapshots will be immediately available at: // https://s01.oss.sonatype.org/content/repositories/snapshots/at/florianschuster/control/ - publishToMavenCentral(SonatypeHost.S01) - signAllPublications() + publishToMavenCentral(automaticRelease = true) + if (version != "local") signAllPublications() coordinates(group.toString(), "control-core", version.toString()) pom { name = "control-core" diff --git a/examples/android-counter/build.gradle.kts b/examples/android-counter/build.gradle.kts index 131bdc51..be399da4 100644 --- a/examples/android-counter/build.gradle.kts +++ b/examples/android-counter/build.gradle.kts @@ -1,9 +1,19 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { id("kotlin-android") alias(libs.plugins.android.application) alias(libs.plugins.compose.compiler) } +val jenvContent = File(".java-version").readText().trim() + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.fromTarget(jenvContent)) + } +} + android { namespace = "at.florianschuster.control.counter" compileSdk = libs.versions.android.compileSdk.get().toInt() @@ -14,8 +24,9 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + val javaVersion = JavaVersion.toVersion(jenvContent) + sourceCompatibility = javaVersion + targetCompatibility = javaVersion } sourceSets["main"].java.srcDir("src/main/kotlin") sourceSets["test"].java.srcDir("src/test/kotlin") @@ -25,7 +36,6 @@ android { resources.excludes.add("META-INF/LGPL2.1") } buildFeatures { compose = true } - kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() } } dependencies { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index df64bd2e..aa52c9ba 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,22 +1,22 @@ [versions] # lib -kotlin = "2.1.10" -coroutines = "1.10.1" -binary-compatibility-validator = "0.17.0" +kotlin = "2.3.0" +coroutines = "1.10.2" +binary-compatibility-validator = "0.18.1" # examples -agp = "8.9.0" +agp = "8.13.2" android-minSdk = "28" -android-compileSdk = "35" -androidx-activity-compose = "1.10.1" -androidx-compose-bom = "2025.03.00" -androidx-material3 = "1.3.1" -espresso = "3.6.1" -junit-ktx = "1.2.1" -maven-publish-plugin = "0.30.0" -kover = "0.9.1" -ktlint = "12.2.0" -androidx-ui-test-junit4 = "1.7.8" +android-compileSdk = "36" +androidx-activity-compose = "1.12.2" +androidx-compose-bom = "2026.01.00" +androidx-material3 = "1.4.0" +espresso = "3.7.0" +junit-ktx = "1.3.0" +maven-publish-plugin = "0.36.0" +kover = "0.9.4" +ktlint = "14.0.1" +androidx-ui-test-junit4 = "1.10.1" [libraries] # lib diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 58c3237e..db2c5240 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip From 3475d1268d058b324dc1e93ee8284131f8810151 Mon Sep 17 00:00:00 2001 From: flosch Date: Thu, 22 Jan 2026 21:57:15 +0100 Subject: [PATCH 2/3] Add skill --- .opencode/skills/floschu-control.md | 538 ++++++++++++++++++++++++++++ 1 file changed, 538 insertions(+) create mode 100644 .opencode/skills/floschu-control.md diff --git a/.opencode/skills/floschu-control.md b/.opencode/skills/floschu-control.md new file mode 100644 index 00000000..bfac5b3b --- /dev/null +++ b/.opencode/skills/floschu-control.md @@ -0,0 +1,538 @@ +--- +name: floschu-control +description: Implement, debug, and test floschu/control - a unidirectional data flow state management kmp library with coroutines +license: Apache-2.0 +compatibility: opencode +metadata: + language: kotlin + framework: kotlin-multiplatform +--- + + +# Control Library Skill + +A skill for working with [control](https://github.com/floschu/control) - a Kotlin Multiplatform unidirectional data flow (UDF) library. + +## Overview + +Control is a UI-independent state management library that separates business logic from view logic using the UDF pattern. Controllers have no dependency on views, making them easy to unit test. + +### Core Architecture + +``` +Action -> Mutator -> [0..n] Mutations -> Reducer -> New State +``` + +``` + Action + ┏━━━━━━━━━━━━━━━━━━━━━│━━━━━━━━━━━━━━━━━┓ + ┃ │ ┃ + ┃ ┏━━━━━▼━━━━━┓ ┃ side effect ┏━━━━━━━━━━━━━━━━━━━━┓ + ┃ ┃ mutator ◀────────────────────────────▶ service/usecase ┃ + ┃ ┗━━━━━━━━━━━┛ ┃ ┗━━━━━━━━━━━━━━━━━━━━┛ + ┃ │ ┃ + ┃ │ 0..n mutations ┃ + ┃ │ ┃ + ┃ ┏━━━━━▼━━━━━┓ ┃ + ┃ ┌───────────▶┃ reducer ┃ ┃ + ┃ │ ┗━━━━━━━━━━━┛ ┃ + ┃ │ previous │ ┃ + ┃ │ state │ new state ┃ + ┃ │ │ ┃ + ┃ │ ┏━━━━━▼━━━━━┓ ┃ + ┃ └────────────┃ state ┃ ┃ + ┃ ┗━━━━━━━━━━━┛ ┃ + ┃ │ ┃ + ┗━━━━━━━━━━━━━━━━━━━━━│━━━━━━━━━━━━━━━━━┛ + ▼ + state +``` + +## Installation + +Add the dependency to your Kotlin Multiplatform project: + +```groovy +repositories { + mavenCentral() +} + +kotlin { + sourceSets { + commonMain { + dependencies { + implementation("at.florianschuster.control:control-core:$version") + } + } + } +} +``` + +## Creating a Basic Controller + +### Step 1: Define Actions + +Actions represent user intents or events that trigger state changes. Define them as a sealed interface: + +```kotlin +sealed interface CounterAction { + data object Increment : CounterAction + data object Decrement : CounterAction +} +``` + +### Step 2: Define Mutations (Private) + +Mutations are internal state change descriptors. Keep them private to the controller: + +```kotlin +private sealed interface CounterMutation { + data object IncreaseValue : CounterMutation + data object DecreaseValue : CounterMutation + data class SetLoading(val loading: Boolean) : CounterMutation +} +``` + +### Step 3: Define State + +State is an immutable data class representing the current state: + +```kotlin +data class CounterState( + val value: Int = 0, + val loading: Boolean = false +) +``` + +### Step 4: Create the Controller + +Use `CoroutineScope.createController()` to build the controller: + +```kotlin +typealias CounterController = Controller + +fun CoroutineScope.createCounterController( + initialValue: Int = 0 +): CounterController = createController( + initialState = CounterState(value = initialValue), + + mutator = { action -> + when (action) { + is CounterAction.Increment -> flow { + emit(CounterMutation.SetLoading(true)) + delay(500.milliseconds) + emit(CounterMutation.IncreaseValue) + emit(CounterMutation.SetLoading(false)) + } + is CounterAction.Decrement -> flow { + emit(CounterMutation.SetLoading(true)) + delay(500.milliseconds) + emit(CounterMutation.DecreaseValue) + emit(CounterMutation.SetLoading(false)) + } + } + }, + + reducer = { mutation, previousState -> + when (mutation) { + is CounterMutation.IncreaseValue -> previousState.copy(value = previousState.value + 1) + is CounterMutation.DecreaseValue -> previousState.copy(value = previousState.value - 1) + is CounterMutation.SetLoading -> previousState.copy(loading = mutation.loading) + } + } +) +``` + +## Key Components + +### Controller Interface + +The core interface with two members: + +```kotlin +interface Controller { + fun dispatch(action: Action) // Send actions to be processed + val state: StateFlow // Observe state changes +} +``` + +### Mutator + +Transforms actions into a Flow of mutations. Has access to `MutatorContext`: + +```kotlin +typealias Mutator = + MutatorContext.(action: Action) -> Flow + +interface MutatorContext { + val currentState: State // Access current state + val actions: Flow // Access actions flow for combining +} +``` + +**Mutator patterns:** + +```kotlin +mutator = { action -> + when(action) { + // Emit no mutations + is Action.NoOp -> emptyFlow() + + // Emit single mutation + is Action.Simple -> flowOf(Mutation.DoSomething) + + // Emit multiple mutations (async operations) + is Action.LoadData -> flow { + emit(Mutation.SetLoading(true)) + val data = repository.fetchData() // Suspend call + emit(Mutation.SetData(data)) + emit(Mutation.SetLoading(false)) + } + + // Access current state + is Action.Toggle -> flowOf( + Mutation.SetEnabled(!currentState.isEnabled) + ) + } +} +``` + +### Reducer + +Synchronously transforms mutations into new state: + +```kotlin +typealias Reducer = + ReducerContext.(mutation: Mutation, previousState: State) -> State + +reducer = { mutation, previousState -> + when(mutation) { + is Mutation.SetLoading -> previousState.copy(loading = mutation.loading) + is Mutation.SetData -> previousState.copy(data = mutation.data) + is Mutation.SetEnabled -> previousState.copy(isEnabled = mutation.enabled) + } +} +``` + +### Transformers + +Transform flows of actions, mutations, or states: + +```kotlin +// Initial action on start +actionsTransformer = { actions -> + actions.onStart { emit(Action.InitialLoad) } +} + +// Merge global streams +mutationsTransformer = { mutations -> + merge(mutations, userSession.map { Mutation.SetSession(it) }) +} + +// Logging state changes +statesTransformer = { states -> + states.onEach { println("New State: $it") } +} +``` + +## EffectController + +For one-off side effects (toasts, navigation, snackbars): + +```kotlin +interface EffectController : Controller { + val effects: Flow // Fan-out delivery (one emission per collector) +} +``` + +### Creating an EffectController + +```kotlin +sealed interface MyEffect { + data class ShowToast(val message: String) : MyEffect + data object NavigateBack : MyEffect +} + +fun CoroutineScope.createMyController(): EffectController = + createEffectController( + initialState = MyState(), + + mutator = { action -> + when (action) { + is MyAction.Save -> flow { + emit(Mutation.SetLoading(true)) + try { + repository.save(currentState.data) + emitEffect(MyEffect.ShowToast("Saved!")) + emitEffect(MyEffect.NavigateBack) + } catch (e: Exception) { + emitEffect(MyEffect.ShowToast("Error: ${e.message}")) + } + emit(Mutation.SetLoading(false)) + } + } + }, + + reducer = { mutation, previousState -> + // Can also emit effects in reducer + when (mutation) { + is Mutation.SetError -> { + emitEffect(MyEffect.ShowToast(mutation.error)) + previousState.copy(error = mutation.error) + } + else -> previousState + } + } + ) +``` + +## Configuration Options + +### ControllerLog + +Configure logging for debugging: + +```kotlin +createController( + // ... + controllerLog = ControllerLog.None, // No logging (default) + controllerLog = ControllerLog.Println, // Print to console + controllerLog = ControllerLog.Custom { message -> + Timber.d(message) // Custom logger + } +) +``` + +### ControllerStart + +Control when the state machine starts: + +```kotlin +createController( + // ... + controllerStart = ControllerStart.Lazy, // Start on first access (default) + controllerStart = ControllerStart.Immediately // Start immediately on creation +) +``` + +### Custom Dispatcher + +Override the coroutine dispatcher: + +```kotlin +createController( + // ... + dispatcher = Dispatchers.Default // Or any custom dispatcher +) +``` + +## Testing + +### Controller Testing + +Test controllers directly by dispatching actions and asserting state: + +```kotlin +class CounterControllerTest { + + @Test + fun `increment increases value`() = runTest { + val controller = createCounterController(initialValue = 0) + + controller.dispatch(CounterAction.Increment) + advanceUntilIdle() + + assertEquals(1, controller.state.value.value) + } +} +``` + +### View Testing with Stubs + +Use `ControllerStub` to test views in isolation: + +```kotlin +@OptIn(TestOnlyStub::class) +class CounterViewTest { + + @Test + fun `view displays correct state`() { + val controller = scope.createCounterController().toStub() + + // Emit test state + controller.emitState(CounterState(value = 42, loading = false)) + + // Assert view displays "42" + } + + @Test + fun `button dispatches increment action`() { + val controller = scope.createCounterController().toStub() + + // Simulate button click + incrementButton.performClick() + + // Verify action was dispatched + assertEquals( + listOf(CounterAction.Increment), + controller.dispatchedActions + ) + } +} +``` + +### EffectController Stub + +```kotlin +@OptIn(TestOnlyStub::class) +class MyViewTest { + + @Test + fun `shows toast on effect`() { + val controller = scope.createMyController().toStub() + + // Emit test effect + controller.emitEffect(MyEffect.ShowToast("Test message")) + + // Assert toast is shown + } +} +``` + +## View Integration + +### Jetpack Compose (Android) + +```kotlin +@Composable +fun CounterScreen( + controller: CounterController = viewModelScope.createCounterController() +) { + val state by controller.state.collectAsState() + + Column { + Text(text = "Count: ${state.value}") + + Button( + onClick = { controller.dispatch(CounterAction.Increment) }, + enabled = !state.loading + ) { + Text("Increment") + } + + Button( + onClick = { controller.dispatch(CounterAction.Decrement) }, + enabled = !state.loading + ) { + Text("Decrement") + } + + if (state.loading) { + CircularProgressIndicator() + } + } +} +``` + +### Collecting Effects + +```kotlin +@Composable +fun MyScreen(controller: EffectController) { + val context = LocalContext.current + + LaunchedEffect(controller) { + controller.effects.collect { effect -> + when (effect) { + is MyEffect.ShowToast -> { + Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show() + } + is MyEffect.NavigateBack -> { + // Handle navigation + } + } + } + } + + // Rest of UI... +} +``` + +## Best Practices + +1. **Keep Mutations private**: Mutations are implementation details of the controller +2. **Use immutable State**: Always use `data class` with `copy()` for state updates +3. **Single source of truth**: State should be the only source of truth for the view +4. **Side effects in mutator**: Perform async operations (API calls, DB access) in the mutator +5. **Pure reducers**: Reducers should be pure functions with no side effects +6. **Use EffectController for one-off events**: Navigation, toasts, and snackbars should use effects +7. **Test controllers independently**: Controllers have no view dependency, test them in isolation + +## Common Patterns + +### Loading/Error/Success Pattern + +```kotlin +data class DataState( + val data: List = emptyList(), + val loading: Boolean = false, + val error: String? = null +) + +private sealed interface DataMutation { + data object SetLoading : DataMutation + data class SetData(val data: List) : DataMutation + data class SetError(val error: String) : DataMutation +} + +mutator = { action -> + when (action) { + is DataAction.Load -> flow { + emit(DataMutation.SetLoading) + try { + val data = repository.loadData() + emit(DataMutation.SetData(data)) + } catch (e: Exception) { + emit(DataMutation.SetError(e.message ?: "Unknown error")) + } + } + } +} + +reducer = { mutation, previousState -> + when (mutation) { + is DataMutation.SetLoading -> previousState.copy(loading = true, error = null) + is DataMutation.SetData -> previousState.copy(data = mutation.data, loading = false) + is DataMutation.SetError -> previousState.copy(error = mutation.error, loading = false) + } +} +``` + +### Debounce Search Pattern + +```kotlin +actionsTransformer = { actions -> + actions.debounce { action -> + if (action is SearchAction.Query) 300.milliseconds else Duration.ZERO + } +} +``` + +### Cancelling Previous Operations + +```kotlin +mutator = { action -> + when (action) { + is SearchAction.Query -> flow { + emit(SearchMutation.SetLoading(true)) + val results = searchService.search(action.query) + emit(SearchMutation.SetResults(results)) + emit(SearchMutation.SetLoading(false)) + }.takeUntil(actions.filterIsInstance()) + } +} +``` + +## changelog + +See the [changelog](https://github.com/floschu/control/blob/develop/CHANGELOG.md) for versions. \ No newline at end of file From f49afb90e2cf020bd9d2fa93e479b5f44ecdcddc Mon Sep 17 00:00:00 2001 From: flosch Date: Thu, 22 Jan 2026 22:42:48 +0100 Subject: [PATCH 3/3] Update skill --- README.md | 2 +- {.opencode/skills => skills}/floschu-control.md | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) rename {.opencode/skills => skills}/floschu-control.md (99%) diff --git a/README.md b/README.md index ad295db5..f5ed1f90 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ unit tested. ## skills -* [control-library](.opencode/skills/control-library.md): comprehensive guide for AI agents to implement state management using the control library. +Check out the [control skill](skills/floschu-control.md) to implement and test `control` with your **AI agents**. ## author diff --git a/.opencode/skills/floschu-control.md b/skills/floschu-control.md similarity index 99% rename from .opencode/skills/floschu-control.md rename to skills/floschu-control.md index bfac5b3b..cab5ddc4 100644 --- a/.opencode/skills/floschu-control.md +++ b/skills/floschu-control.md @@ -1,11 +1,6 @@ --- name: floschu-control description: Implement, debug, and test floschu/control - a unidirectional data flow state management kmp library with coroutines -license: Apache-2.0 -compatibility: opencode -metadata: - language: kotlin - framework: kotlin-multiplatform ---