Skip to content

Commit

Permalink
2.4.0 (#38)
Browse files Browse the repository at this point in the history
  • Loading branch information
Nek-12 authored Feb 11, 2024
2 parents e705aba + 1cca8ec commit 6403ec7
Show file tree
Hide file tree
Showing 55 changed files with 960 additions and 551 deletions.
3 changes: 2 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,5 @@ jobs:
env:
ORG_GRADLE_PROJECT_signingPassword: ${{ secrets.SIGNING_PASSWORD }}
ORG_GRADLE_PROJECT_signingKey: ${{ secrets.SIGNING_KEY }}
run: ./gradlew publishAllPublicationsToSonatypeRepository --stacktrace
# It's important to not upload in parallel or duplicate repos will be created
run: ./gradlew publishAllPublicationsToSonatypeRepository -Dorg.gradle.parallel=false --stacktrace
139 changes: 91 additions & 48 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# FlowMVI 2.0
# FlowMVI

[![CI](https://github.com/respawn-app/FlowMVI/actions/workflows/ci.yml/badge.svg)](https://github.com/respawn-app/FlowMVI/actions/workflows/ci.yml)
![License](https://img.shields.io/github/license/respawn-app/flowMVI)
Expand Down Expand Up @@ -26,7 +26,9 @@ FlowMVI is a Kotlin Multiplatform MVI library based on coroutines that has a few
* Latest version:
[![Maven Central](https://img.shields.io/maven-central/v/pro.respawn.flowmvi/core?label=Maven%20Central)](https://central.sonatype.com/namespace/pro.respawn.flowmvi)

### Version Catalogs
<details>
<summary>Version catalogs</summary>

```toml
[versions]
flowmvi = "< Badge above 👆🏻 >"
Expand All @@ -40,7 +42,12 @@ flowmvi-android = { module = "pro.respawn.flowmvi:android", version.ref = "flowm
flowmvi-view = { module = "pro.respawn.flowmvi:android-view", version.ref = "flowmvi" } # view-based android
flowmvi-savedstate = { module = "pro.respawn.flowmvi:savedstate", version.ref = "flowmvi" } # KMP state preservation
```
### Kotlin DSL

</details>

<details>
<summary>Gradle DSL</summary>

```kotlin
dependencies {
val flowmvi = "< Badge above 👆🏻 >"
Expand All @@ -54,9 +61,29 @@ dependencies {
}
```

## Features:
</details>

Rich store DSL with dozens of useful pre-made plugins:
## Why FlowMVI?

* Fully async and parallel business logic - with no manual thread synchronization required!
* Automatically recover from any errors and avoid runtime crashes with one line of code
* Build fully-multiplatform business logic with pluggable UI
* Create compile-time safe state machines with a readable DSL. Forget about `state as? ...` casts
* Automatic platform-independent system lifecycle handling with hooks on subscription
* Restartable, reusable stores with no external dependencies or dedicated lifecycles.
* Compress, persist, and restore state automatically with a single line of code - on any platform
* Out of the box debugging, logging, testing and long-running task management support
* Decompose stores into plugins, split responsibilities, and modularize the project easily
* No base classes or complicated interfaces - store is built using a simple DSL
* Use both MVVM+ (functional) or MVI (model-driven) style of programming
* Share, distribute, or disable side-effects based on your team's needs
* Create parent-child relationships between stores and delegate responsibilities
* 70+% unit test coverage of core library code

## How does it look?

<details>
<summary>Define a contract</summary>

```kotlin
sealed interface CounterState : MVIState {
Expand All @@ -77,81 +104,82 @@ sealed interface CounterIntent : MVIIntent {
sealed interface CounterAction : MVIAction {
data class ShowMessage(val message: String) : CounterAction
}
```

</details>

```kotlin
class CounterContainer(
private val repo: CounterRepository,
) {
val store = store<CounterState, CounterIntent, CounterAction>(initial = Loading) {
name = "CounterStore"
parallelIntents = true
coroutineContext = Dispatchers.Default // run all operations on background threads if needed
actionShareBehavior = ActionShareBehavior.Distribute() // disable, share, distribute or consume side effects
coroutineContext = Dispatchers.Default
actionShareBehavior = ActionShareBehavior.Distribute()
intentCapacity = 64

install(
platformLoggingPlugin(), // log to console, logcat or NSLog
analyticsPlugin(name), // create custom plugins
timeTravelPlugin(), // unit test stores and track changes
// log all store activity to console, logcat or NSLog
platformLoggingPlugin(),
// unit test stores and track changes
timeTravelPlugin(),
// undo and redo any actions
undoRedoPlugin(),
)

// one-liner for persisting and restoring compressed state to/from files,
// bundles, or anywhere
// manage named job
val jobManager = manageJobs()

// persist and restore state
serializeState(
dir = repo.cacheDir,
json = Json,
serializer = DisplayingCounter.serializer(),
recover = ThrowRecover
)

val undoRedoPlugin = undoRedo(maxQueueSize = 10) // undo and redo any changes

val jobManager = manageJobs() // manage named jobs
// run actions when store is launched
init { repo.startTimer() }

init { // run actions when store is launched
repo.startTimer()
// recover from errors both in jobs and plugins
recover { e: Exception ->
action(ShowMessage(e.message))
null
}

whileSubscribed { // run a job while any subscribers are present
repo.timer.onEach { timer: Int ->
updateState<DisplayingCounter, _> { // update state safely between threads and filter by type
// run jobs while subscribers are present
whileSubscribed {
repo.timer.collect {
updateState<DisplayingCounter, _> {
copy(timer = timer)
}
}.consume()
}

recover { e: Exception -> // recover from errors both in jobs and plugins
action(ShowMessage(e.message)) // send side-effects
null
}
}

reduce { intent: CounterIntent -> // reduce intents
// install, split, and decompose reducers
reduce { intent: CounterIntent ->
when (intent) {
is ClickedCounter -> updateState<DisplayingCounter, _> {
copy(counter = counter + 1)
}
}
}

parentStore(repo.store) { state -> // one-liner to attach to any other store.
// one-liner to attach to any other store.
parentStore(repo.store) { state ->
updateState {
copy(timer = state.timer)
}
}

install { // build and install custom plugins on the fly
// lazily evaluate and cache values, even when the method is suspending.
val pagingData by cache {
repo.getPagedDataSuspending()
}

install { // build and install custom plugins on the fly
onStop { // hook into various store events
repo.stopTimer()
}

onState { old, new -> // veto changes, modify states, launch jobs, do literally anything
new.withType<DisplayingCounter, _> {
if (counter >= 100) {
launch { repo.resetTimer() }
copy(counter = 0, timer = 0)
} else new
}
}
}
}
}
Expand All @@ -161,16 +189,17 @@ class CounterContainer(

```kotlin
store.subscribe(
scope = consumerCoroutineScope,
scope = coroutineScope,
consume = { action -> /* process side effects */ },
render = { state -> /* render states */ },
)
```

### Custom plugins:

Create plugins with a single line of code for any store or a specific one and hook into all store events:

```kotlin
// Create plugins with a single line of code for any store or a specific one
val counterPlugin = plugin<CounterState, CounterIntent, CounterAction> {
onStart {
/*...*/
Expand All @@ -188,9 +217,9 @@ val counterPlugin = plugin<CounterState, CounterIntent, CounterAction> {
```kotlin
@Composable
fun CounterScreen() {
val store = remember { CounterContainer() } // or use a DI framework
val store = inject<CounterContainer>()

// collect the state and handle events efficiently based on system lifecycle, whether it's iOS or Desktop
// collect the state and handle events efficiently based on system lifecycle - on any platform
val state by store.subscribe { action ->
when (action) {
is ShowMessage -> {
Expand Down Expand Up @@ -224,7 +253,6 @@ class ScreenFragment : Fragment() {

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// One-liner for store subscription. Lifecycle-aware and efficient.
subscribe(vm, ::consume, ::render)
}

Expand All @@ -238,11 +266,12 @@ class ScreenFragment : Fragment() {
}
```

### Testing DSL
## Testing DSL

### Test Stores

```kotlin
// using Turbine + Kotest
testStore().subscribeAndTest {
counterStore().subscribeAndTest {

ClickedCounter resultsIn {
states.test {
Expand All @@ -255,6 +284,20 @@ testStore().subscribeAndTest {
}
```

### Test plugins

```kotlin
val timer = Timer()
timerPlugin(timer).test(Loading) {
onStart()
assert(timeTravel.starts == 1) // keeps track of all plugin operations
assert(state is DisplayingCounter)
assert(timer.isStarted)
onStop(null)
assert(!timer.isStarted)
}
```

Ready to try? Start with reading the [Quickstart Guide](https://opensource.respawn.pro/FlowMVI/#/quickstart).

## License
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION")

package pro.respawn.flowmvi.android.plugins

import android.os.Parcelable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ sealed interface CounterState : MVIState {
@Serializable
@Parcelize
data class DisplayingCounter(
val timer: Int,
val counter: Int,
val input: String,
val timer: Int = 0,
val counter: Int = 0,
val input: String = "",
) : CounterState, Parcelable
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import pro.respawn.flowmvi.sample.CounterIntent.InputChanged
import pro.respawn.flowmvi.sample.CounterState
import pro.respawn.flowmvi.sample.CounterState.DisplayingCounter
import pro.respawn.flowmvi.sample.repository.CounterRepository
import pro.respawn.flowmvi.savedstate.api.ThrowRecover
import pro.respawn.flowmvi.savedstate.api.NullRecover
import pro.respawn.flowmvi.savedstate.plugins.serializeState
import pro.respawn.flowmvi.util.typed
import kotlin.random.Random
Expand All @@ -54,7 +54,7 @@ class CounterContainer(
dir = cacheDir,
json = json,
serializer = DisplayingCounter.serializer(),
recover = ThrowRecover
recover = NullRecover,
)
val undoRedo = undoRedo(10)
val jobManager = manageJobs()
Expand Down Expand Up @@ -104,7 +104,7 @@ class CounterContainer(
}

private suspend fun Ctx.produceState(timer: Int) = updateState {
// remember that you have to merge states when you are running produceState
// merge states
val current = typed<DisplayingCounter>()
DisplayingCounter(
timer = timer,
Expand Down
44 changes: 16 additions & 28 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask
import nl.littlerobots.vcu.plugin.versionCatalogUpdate
import nl.littlerobots.vcu.plugin.versionSelector
import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnLockMismatchReport
import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnPlugin
import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnRootExtension
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

Expand All @@ -9,7 +10,6 @@ private val PluginPrefix = "plugin:androidx.compose.compiler.plugins.kotlin:repo
plugins {
alias(libs.plugins.detekt)
alias(libs.plugins.gradleDoctor)
alias(libs.plugins.versions)
alias(libs.plugins.version.catalog.update)
alias(libs.plugins.dokka)
alias(libs.plugins.atomicfu)
Expand All @@ -27,8 +27,8 @@ allprojects {
version = Config.versionName
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
jvmTarget.set(Config.jvmTarget)
languageVersion.set(Config.kotlinVersion)
jvmTarget = Config.jvmTarget
languageVersion = Config.kotlinVersion
freeCompilerArgs.apply {
addAll(Config.jvmCompilerArgs)
if (project.findProperty("enableComposeCompilerReports") == "true") {
Expand Down Expand Up @@ -89,12 +89,14 @@ dependencies {
}

versionCatalogUpdate {
sortByKey.set(true)
sortByKey = true

versionSelector { stabilityLevel(it.candidate.version) >= Config.minStabilityLevel }

keep {
keepUnusedVersions.set(true)
keepUnusedLibraries.set(true)
keepUnusedPlugins.set(true)
keepUnusedVersions = true
keepUnusedLibraries = true
keepUnusedPlugins = true
}
}

Expand Down Expand Up @@ -133,26 +135,12 @@ tasks {
description = "Run detekt on whole project"
autoCorrect = false
}

withType<DependencyUpdatesTask>().configureEach {
outputFormatter = "json"

fun stabilityLevel(version: String): Int {
Config.stabilityLevels.forEachIndexed { index, postfix ->
val regex = """.*[.\-]$postfix[.\-\d]*""".toRegex(RegexOption.IGNORE_CASE)
if (version.matches(regex)) return index
}
return Config.stabilityLevels.size
}

rejectVersionIf {
stabilityLevel(currentVersion) > stabilityLevel(candidate.version)
}
}
}

extensions.findByType<YarnRootExtension>()?.run {
yarnLockMismatchReport = YarnLockMismatchReport.WARNING
reportNewYarnLock = true
yarnLockAutoReplace = false
rootProject.plugins.withType<YarnPlugin>().configureEach {
rootProject.the<YarnRootExtension>().apply {
yarnLockMismatchReport = YarnLockMismatchReport.WARNING // NONE | FAIL | FAIL_AFTER_BUILD
reportNewYarnLock = false // true
yarnLockAutoReplace = false // true
}
}
Loading

0 comments on commit 6403ec7

Please sign in to comment.