diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 00000000..268f20d4 --- /dev/null +++ b/.cursorignore @@ -0,0 +1,9 @@ +# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv) +**/.kotlin/ +**/.gradle/ +**/.idea/ +**/build +.idea +.kotlin +.build +.gradle diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index f097e0cf..a470a7a0 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -55,7 +55,9 @@ jobs: cache-dependency-path: './docs/package-lock.json' - name: Update docs/README.md - run: cp ./README.md ./docs/docs/README.md + run: | + chmod -R +x ./scripts + ./scripts/update_readme.sh - name: Install docs deps run: cd docs && npm ci diff --git a/.gitignore b/.gitignore index 94a3eaf2..f701f3bc 100644 --- a/.gitignore +++ b/.gitignore @@ -162,6 +162,7 @@ hs_err_pid* /.idea/other.xml ### Custom rules +.kotlin .firebase-service-account.json /.idea/artifacts/** !gradle/gradle-wrapper.jar diff --git a/README.md b/README.md index 1a014562..139a010c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![](https://opensource.respawn.pro/FlowMVI/banner.png) +![FlowMVI Framework Banner](https://opensource.respawn.pro/FlowMVI/banner.webp) [![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) @@ -384,7 +384,13 @@ timerPlugin(timer).test(Loading) { IDE plugin generates code and lets you debug and control your app remotely: [![Plugin](https://img.shields.io/jetbrains/plugin/v/25766?style=flat)](https://plugins.jetbrains.com/plugin/25766-flowmvi) -https://github.com/user-attachments/assets/05f8efdb-d125-4c4a-9bda-79875f22578f + ## People love the library: diff --git a/docs/docs/README.md b/docs/docs/README.md index d9fb2a47..3429f7ea 100644 --- a/docs/docs/README.md +++ b/docs/docs/README.md @@ -1,3 +1,13 @@ +--- +title: FlowMVI +title_meta: FlowMVI - Kotlin Architecture Framework +sidebar_label: Home +sidebar_position: 0 +hide_title: true +description: Architecture Framework for Kotlin. Reuse every line of code. Handle all errors automatically. No boilerplate. Analytics, metrics, debugging in 3 lines. 50+ features. +slug: / +--- + # FlowMVI ### Stub readme file, do not edit! diff --git a/docs/docs/integrations/_category_.json b/docs/docs/integrations/_category_.json index 53d80578..24e3bf2d 100644 --- a/docs/docs/integrations/_category_.json +++ b/docs/docs/integrations/_category_.json @@ -1,5 +1,5 @@ { - "position": 3, + "position": 4, "label": "Integrations", "collapsible": true, "collapsed": false diff --git a/docs/docs/misc/CONTRIBUTING.md b/docs/docs/misc/CONTRIBUTING.md index 25e2bdac..06377cf7 100644 --- a/docs/docs/misc/CONTRIBUTING.md +++ b/docs/docs/misc/CONTRIBUTING.md @@ -1,5 +1,5 @@ --- -sidebar_position: 2 +sidebar_position: 3 sidebar_label: Contribution guide --- diff --git a/docs/docs/misc/FAQ.md b/docs/docs/misc/FAQ.md index 71021425..bdffc719 100644 --- a/docs/docs/misc/FAQ.md +++ b/docs/docs/misc/FAQ.md @@ -1,6 +1,10 @@ +--- +sidebar_position: 2 +--- + # FAQ -### "Cannot inline bytecode" error +### How to fix "Cannot inline bytecode" error? The library's minimum JVM target is set to 11 (sadly still not the default in Gradle). If you encounter an error: @@ -20,8 +24,6 @@ kotlin { } } } - - ``` And in your android gradle files, set: @@ -38,36 +40,9 @@ android { If you support Android API \<26, you will also need to enable [desugaring](https://developer.android.com/studio/write/java8-support). +### How to name Intents, States, Actions? -### Tips: - -* Avoid using `sealed class`es and use `sealed interface`s whenever possible. Not only this reduces object allocations, - but also prevents developers from putting excessive logic into their states and/or making private/protected - properties. State is a simple typed data holder, so if you want to use protected properties or override functions, - it is likely that something is wrong with your architecture. -* Use nested class imports and import aliases to clean up your code, as contract class names can be long sometimes. -* Use value classes to reduce object allocations if your Intents are being sent frequently, such as for text field - value changes or scroll events. - * You can use the `updateStateImmediate` function to optimize the - performance of the store by bypassing all checks and plugins. - * Overall, there are cases when changes are so frequent that you'll want to just leave some logic on the UI layer to - avoid polluting the heap with garbage collected objects and keep the UI performant. -* Avoid subscribing to a bunch of flows in your Store. The best way to implement a reactive UI pattern is to - use `combine(vararg flows...)` and merge all of your data streams into one flow, and then just use the `transform` - block to handle the changes. - * With this, you can be sure that your state is consistent even if you have 20 parallel data streams from different - sources e.g. database cache, network, websockets and other objects. -* Avoid using platform-level imports and code in your Store/Container/ViewModel whenever possible. This is optional, but - if you follow this rule, your **Business logic can be multiplatform**! This is also very good for the architecture. -* There is an ongoing discussion about whether to name your intents starting with the verb or with the noun. - * Case 1: `ClickedCounter` - * Case 2: `CounterClicked` - In general, this is up to your personal preference, just make sure you use a single style across all of your - Contracts. I personally like to name intents starting with the verb (Case 1) for easier autosuggestions from the - IDE. - -### Opinionated naming design - +There is an ongoing discussion on how to name Intents/States/Actions. Here's an example of rules we use at [Respawn](https://respawn.pro) to name our Contract classes: * `MVIIntent` naming should be ``. @@ -77,8 +52,6 @@ Here's an example of rules we use at [Respawn](https://respawn.pro) to name our Do not include `Screen` postfix. `GoToHome`~~Screen~~. * `MVIState`s should be named using verbs in present tense using a gerund. Examples: `EditingGame`, `DisplayingSignIn`. -## FAQ - ### My intents are not reduced! When I click buttons, nothing happens, the app just hangs. * Did you call `Store.start(scope: CoroutineScope)`? @@ -95,27 +68,15 @@ Here's an example of rules we use at [Respawn](https://respawn.pro) to name our subscribe to actions. 4. Try to use an `onUndeliveredIntent` handler of a plugin or install a logging plugin to debug missed events. -### Why does `updateState` and `withState` not return the resulting state? Why is there no `state` property I can access? - -FlowMVI is a framework that enables you to build highly parallel, multi-threaded systems. In such systems, multiple -threads may modify the state of the `Store` in parallel, leading to data races, thread races, live locks and other -nasty problems. To prevent that, FlowMVI implements a strategy called "transaction serialization" which only allows -**one** client at a time to read or modify the state. Because of that, you can be sure that your state won't change -unexpectedly while you're working with it. However, any state that you pass outside of the scope of `withState` or -`updateState` should be **considered invalid** immediately. You can read more about serializable state transactions in -the [article](https://proandroiddev.com/how-to-safely-update-state-in-your-kotlin-apps-bf51ccebe2ef). -Difficulties that you are facing because of this likely have an easy solution that requires a bit more thinking. -As you continue working with FlowMVI, updating states safely will come naturally to you. - ### In what order are intents, plugins and actions processed? -* Intents: FIFO or undefined based on the configuration parameter `parallelIntents`. -* Actions: FIFO. -* States: FIFO. -* Plugins: FIFO (Chain of Responsibility) based on installation order. -* Decorators: FIFO, but after all of the regular plugins. +* Intents: FIFO or undefined based on the configuration parameter `parallelIntents` +* Actions: FIFO +* States: FIFO +* Plugins: FIFO (Chain of Responsibility) based on installation order +* Decorators: FIFO, but after all of the regular plugins -### When I consume an Action, the other actions are delayed or do not come. +### When I consume an Action, the other actions are delayed or do not come Since actions are processed sequentially, make sure you launch a coroutine to not prevent other actions from coming and suspending the scope. This is particularly obvious with things like snackbars that suspend in compose. @@ -126,13 +87,12 @@ You shouldn't. Use an Intent / Action to follow the contract, unless you are usi In that case, expose the parent `ImmutableContainer` / `ImmutableStore` type to hide the `intent` function from subscribers. -### How to use paging? +### How to use androidx.paging? Well, this is a tricky one. `androidx.paging` breaks the architecture by invading all layers of your app with UI logic. The best solution we could come up with is just passing a PagingFlow as a property in the state. This is not good, because the state becomes mutable and non-stable, but there's nothing better we could come up with, but it does its job, as long as you are careful not to recreate the flow and pass it around between states. -If you have an idea or a working Paging setup, let us know and we can add it to the library! The Paging library also relies on the `cachedIn` operator which is tricky to use in `whileSubscribed`, because that block is rerun on every subscription, recreating and re-caching the flow. @@ -145,23 +105,13 @@ val pagingFlow by cache { } ``` -### I have like a half-dozen various flows or coroutines and I want to make my state from those data streams. Do I subscribe to all of those flows in my store? +### I have a lot of data streams. Do I subscribe to all of the flows in my store? It's preferable to create a single flow using `combine(vararg flows...)` and produce your state based on that. This will ensure that your state is consistent and that there are no unnecessary races in your logic. As flows add up, it will become harder and harder to keep track of things if you use `updateState` and `collect`. -### How do I handle errors? - -There are two ways to do this. - -1. First one is using one of the Result wrappers, like [ApiResult](https://github.com/respawn-app/apiresult), a monad - from Arrow.io or, as the last resort, a `kotlin.Result`. -2. Second one involves using a provided `recover` plugin that will be run when an exception is - caught in plugins or child coroutines, but the plugin will be run **after** the job was already cancelled, so you - cannot continue the job execution anymore. - -### But that other library allows me to define 9000 handlers, actors, processors and whatnot - and I can reuse reducers. Why not do the same? +### But that other library has 9000 handlers, reducers and whatnot. Why not do the same? In general, a little boilerplate when duplicating intents is worth it to keep the consistency of actions and intents of screens intact. @@ -188,43 +138,13 @@ fun StoreBuilder.reduce( ### How to avoid class explosion? -1. Modularize your app. The library allows you to do that easily. -2. Use nested classes. For example, you can define an `object ScreenContract` and nest your state, intents, and actions +1. Modularize the app. The library allows to do that easily. +2. Use nested classes. For example, define an `object ScreenContract` and nest your state, intents, and actions inside to make autocompletion easier. 3. Use `LambdaIntent`s. They don't require subclassing `MVIIntent`. 4. Disallow Actions for your store. Side effects are sometimes considered an anti-pattern, and you may want to disable them if you care about the architecture this much. -### What if I have sub-states or multiple Loading states for different parts of the screen? - -Create nested classes and host them in your parent state. -Example: - -```kotlin -sealed interface NewsState : MVIState { - data object Loading : NewsState - data class DisplayingNews( - val suggestionsState: SuggestionsState, - val feedState: FeedState, - ) : NewsState { - sealed interface SuggestionsState { - data object Loading : SuggestionsState - data class DisplayingSuggestions(val suggestions: List) : SuggestionsState - } - - sealed interface FeedState { - data object Loading : FeedState - data class DisplayingFeed(val news: List) : FeedState - } - } -} -``` - -* Use `T.withType(block: Type.() -> Unit)` to cast your sub-states easier as - the `(this as? State)?.let { } ?: this` code can look ugly. -* Use `T.typed()` to perform a safe cast to the given state to clean up the code. -* You don't have to have a top-level sealed interface. If it's simpler, you can just use a data class on the top level. - ### I want to use a resource or a framework dependency in my store. How can I do that? The best solution would be to avoid using platform dependencies such as string resources. diff --git a/docs/docs/plugins/debugging.md b/docs/docs/misc/debugging.md similarity index 99% rename from docs/docs/plugins/debugging.md rename to docs/docs/misc/debugging.md index 11ffa4cd..0f7658ee 100644 --- a/docs/docs/plugins/debugging.md +++ b/docs/docs/misc/debugging.md @@ -1,5 +1,5 @@ --- -sidebar_position: 5 +sidebar_position: 1 --- diff --git a/docs/docs/plugins/custom.md b/docs/docs/plugins/custom.md index 6de18381..c52f5b7e 100644 --- a/docs/docs/plugins/custom.md +++ b/docs/docs/plugins/custom.md @@ -1,5 +1,6 @@ --- sidebar_position: 2 +sidebar_label: Making Custom Plugins --- # Creating custom plugins diff --git a/docs/docs/plugins/prebuilt.md b/docs/docs/plugins/prebuilt.md index fa63c1e9..53bdce07 100644 --- a/docs/docs/plugins/prebuilt.md +++ b/docs/docs/plugins/prebuilt.md @@ -1,5 +1,6 @@ --- sidebar_position: 1 +sidebar_label: Installing & Using Plugins --- # Getting started with plugins @@ -86,33 +87,33 @@ FlowMVI comes with a whole suite of prebuilt plugins to cover the most common de Here's a full list: -* **Reduce Plugin** - process incoming intents. Install with `reduce { }`. -* **Init Plugin** - do something when the store is launched. Install with `init { }`. -* **Recover Plugin** - handle exceptions, works for both plugins and jobs. Install with `recover { }`. -* **While Subscribed Plugin** - run jobs when the `N`th subscriber of a store appears. Install - with `whileSubscribed { }`. -* **Logging Plugin** - log events to a log stream of the target platform. Install with `enableLogging()` -* **Cache Plugin** - cache values in store's scope lazily and with the ability to suspend, binding them to the store's - lifecycle. Install with `val value by cache { }` -* **Async cache plugin** - like `cache`, but returns a `Deferred` that can be awaited. Advantageous because it does not - delay the store's startup sequence. -* **Job Manager Plugin** - keep track of long-running tasks, cancel and schedule them. Install with `manageJobs()`. -* **Await Subscribers Plugin** - let the store wait for a specified number of subscribers to appear before starting its - work. Install with `awaitSubscribers()`. -* **Undo/Redo Plugin** - undo and redo any action happening in the store. Install with `undoRedo()`. -* **Disallow Restart Plugin** - disallow restarting the store if you do not plan to reuse it. - Install with `disallowRestart()`. -* **Time Travel Plugin** - keep track of state changes, intents and actions happening in the store. Mostly used for - testing, debugging and when building other plugins. Install with `val timeTravel = timeTravel()` -* **Consume Intents Plugin** - permanently consume intents that reach this plugin's execution order. Install with - `consumeIntents()`. -* **Deinit Plugin** - run actions when the store is stopped. -* **Reset State Plugin** - reset the state of the store when it is stopped. -* **Saved State Plugin** - Save state somewhere else when it changes, and restore when the store starts. - See [saved state](/plugins/savedstate.md) for details. -* **Remote Debugging Plugin** - connect to a remote debugger IDE Plugin / desktop app shipped with FlowMVI. See - the [documentation](/plugins/debugging.md) to learn how to set up the environment. -* **Literally any plugin** - just call `install { }` and use the plugin's scope to hook up to store events. +- **Reduce Plugin** - process incoming intents. Install with `reduce { }`. +- **Init Plugin** - do something when the store is launched. Install with `init { }`. +- **Recover Plugin** - handle exceptions, works for both plugins and jobs. Install with `recover { }`. +- **While Subscribed Plugin** - run jobs when the `N`th subscriber of a store appears. Install + with `whileSubscribed { }`. +- **Logging Plugin** - log events to a log stream of the target platform. Install with `enableLogging()` +- **Cache Plugin** - cache values in store's scope lazily and with the ability to suspend, binding them to the store's + lifecycle. Install with `val value by cache { }` +- **Async cache plugin** - like `cache`, but returns a `Deferred` that can be awaited. Advantageous because it does not + delay the store's startup sequence. +- **Job Manager Plugin** - keep track of long-running tasks, cancel and schedule them. Install with `manageJobs()`. +- **Await Subscribers Plugin** - let the store wait for a specified number of subscribers to appear before starting its + work. Install with `awaitSubscribers()`. +- **Undo/Redo Plugin** - undo and redo any action happening in the store. Install with `undoRedo()`. +- **Disallow Restart Plugin** - disallow restarting the store if you do not plan to reuse it. + Install with `disallowRestart()`. +- **Time Travel Plugin** - keep track of state changes, intents and actions happening in the store. Mostly used for + testing, debugging and when building other plugins. Install with `val timeTravel = timeTravel()` +- **Consume Intents Plugin** - permanently consume intents that reach this plugin's execution order. Install with + `consumeIntents()`. +- **Deinit Plugin** - run actions when the store is stopped. +- **Reset State Plugin** - reset the state of the store when it is stopped. +- **Saved State Plugin** - Save state somewhere else when it changes, and restore when the store starts. + See [saved state](/state/savedstate.md) for details. +- **Remote Debugging Plugin** - connect to a remote debugger IDE Plugin / desktop app shipped with FlowMVI. See + the [documentation](/misc/debugging.md) to learn how to set up the environment. +- **Literally any plugin** - just call `install { }` and use the plugin's scope to hook up to store events. All plugins are based on the essential callbacks that FlowMVI allows them to intercept. The callbacks are explained on the [custom plugins](/plugins/custom.md) page. @@ -138,10 +139,10 @@ fun reducePlugin( } ``` -* This plugin simply executes `reduce` when it receives an intent. -* If you set `consume = true`, the plugin will **not** let other plugins installed after this one receive the intent. - Set `consume = false` to install more than one reduce plugin. -* By default, you can see above that this plugin must be unique. Provide a custom name if you want to have multiple. +- This plugin simply executes `reduce` when it receives an intent. +- If you set `consume = true`, the plugin will **not** let other plugins installed after this one receive the intent. + Set `consume = false` to install more than one reduce plugin. +- By default, you can see above that this plugin must be unique. Provide a custom name if you want to have multiple. Install this plugin in your stores by using @@ -173,10 +174,10 @@ fun initPlugin( Here are some interesting properties that apply to all plugins that use `onStart`: -* They are executed **each time** the store starts. -* They can suspend, and until **all** of them return, the store will **not handle any subscriptions, intents or any - other actions** -* They have a `PipelineContext` receiver which allows you to send intents, side effects and launch jobs +- They are executed **each time** the store starts. +- They can suspend, and until **all** of them return, the store will **not handle any subscriptions, intents or any + other actions** +- They have a `PipelineContext` receiver which allows you to send intents, side effects and launch jobs :::warning[Do not suspend forever] @@ -259,14 +260,14 @@ fun whileSubscribedPlugin( } ``` -* This plugin is designed to suspend inside its `block` because it already launches a background job. -You can safely collect flows and suspend forever in the `block`. -* After the store is started, this plugin will begin receiving subscription events from the store. -* The **first time** the number of plugins reaches the minimum, the block that you provided will be run. -* The job will stay active until it either ends by itself or the number of subscriptions drops below the minimum. -* If the job has ended by itself, it will only be launched **after** the count of subscriptions has dropped below the - minimum. I.e. it will not be relaunched each time an additional subscriber appears, - but only when the condition is satisfied the next time again. +- This plugin is designed to suspend inside its `block` because it already launches a background job. + You can safely collect flows and suspend forever in the `block`. +- After the store is started, this plugin will begin receiving subscription events from the store. +- The **first time** the number of plugins reaches the minimum, the block that you provided will be run. +- The job will stay active until it either ends by itself or the number of subscriptions drops below the minimum. +- If the job has ended by itself, it will only be launched **after** the count of subscriptions has dropped below the + minimum. I.e. it will not be relaunched each time an additional subscriber appears, + but only when the condition is satisfied the next time again. This plugin is useful for starting and stopping observation of some external data sources when the user can interact with the app. For example, you may want to collect some flows and call `updateState` on each emission to update @@ -290,18 +291,18 @@ store. The default `PlatformStoreLogger` will print to: -* Logcat on Android -* NSLog on Apple platforms -* Console on Wasm and JS -* Stdout / Stderr on JVM -* Stdout on other platforms +- Logcat on Android +- NSLog on Apple platforms +- Console on Wasm and JS +- Stdout / Stderr on JVM +- Stdout on other platforms --- -* Tags are only used on Android, so on other platforms they will be appended as a part of the message. -* On platforms that do not support levels, an emoji will be printed instead -* Don't worry about heavy operations inside your `log { }` statements, the lambda is skipped if there is no logger. -* Use `NoOpStoreLogger` if you want to prevent any kind of logging, for example on production. +- Tags are only used on Android, so on other platforms they will be appended as a part of the message. +- On platforms that do not support levels, an emoji will be printed instead +- Don't worry about heavy operations inside your `log { }` statements, the lambda is skipped if there is no logger. +- Use `NoOpStoreLogger` if you want to prevent any kind of logging, for example on production. Install this plugin with: @@ -338,18 +339,18 @@ By default, the entire store startup sequence will suspend until all values are there is a second version of this plugin called `asyncCache` that returns a `Deferred` you can await. This one can be very useful to initialize a lot of heavy stuff in parallel. -* You can create a `CachedValue` outside of the store if you need to access it outside of the store builder scope, - but you **must** install the plugin using the value, and you must **not** try to access the value outside of the - store's lifecycle, or the attempt will throw. To create it, use the `cached { }` delegate. -* You can access the value returned by `cache` in the `onStop` callback because the `onStop` is called in reverse plugin - installation order. +- You can create a `CachedValue` outside of the store if you need to access it outside of the store builder scope, + but you **must** install the plugin using the value, and you must **not** try to access the value outside of the + store's lifecycle, or the attempt will throw. To create it, use the `cached { }` delegate. +- You can access the value returned by `cache` in the `onStop` callback because the `onStop` is called in reverse plugin + installation order. This plugin is most useful: -* When you want to either suspend in the initializer (like a suspending `lazy`), in which case it will function - similarly to `init` plugin -* When you want to use the `PipelineContext` (and its `CoroutineScope`) when initializing a value, for example with - pagination or shared flows +- When you want to either suspend in the initializer (like a suspending `lazy`), in which case it will function + similarly to `init` plugin +- When you want to use the `PipelineContext` (and its `CoroutineScope`) when initializing a value, for example with + pagination or shared flows Install this plugin using: @@ -455,12 +456,12 @@ fun awaitSubscribersPlugin( } ``` -* Specify `minSubs` to determine the minimum number of subscribers to reach. -* Choose `suspendStore` to block all store operations until the condition is met. If you pass `false`, only the code - that explicitly calls `await()` will suspend. -* If you pass the `allowResubscription` parameter, then after they leave, the state will reset and you - can call `await()` again. -* Specify a `timeout` duration or `complete()` the job manually if you want to finish early. +- Specify `minSubs` to determine the minimum number of subscribers to reach. +- Choose `suspendStore` to block all store operations until the condition is met. If you pass `false`, only the code + that explicitly calls `await()` will suspend. +- If you pass the `allowResubscription` parameter, then after they leave, the state will reset and you + can call `await()` again. +- Specify a `timeout` duration or `complete()` the job manually if you want to finish early. ### Undo/Redo Plugin diff --git a/docs/docs/quickstart.md b/docs/docs/quickstart.md index dd031351..4a7fea5a 100644 --- a/docs/docs/quickstart.md +++ b/docs/docs/quickstart.md @@ -443,8 +443,8 @@ Continue learning by reading these articles: 1. Learn how to [install](/plugins/prebuilt.md) and [create](/plugins/custom.md) Plugins. 2. Learn how to use FlowMVI with [compose](/integrations/compose.md) -3. Learn how to [persist and restore state](/plugins/savedstate.md) -4. Set up [remote debugging](/plugins/debugging.md) and DI. +3. Learn how to [persist and restore state](/state/savedstate.md) +4. Set up [remote debugging](/misc/debugging.md) and DI. 5. Learn how to use FlowMVI on [Android](/integrations/android.md) 6. Get answers to common [questions](/misc/FAQ.md) 7. Explore the [sample app](https://github.com/respawn-app/FlowMVI/tree/master/sample/) for code examples diff --git a/docs/docs/state/_category_.json b/docs/docs/state/_category_.json new file mode 100644 index 00000000..f0482149 --- /dev/null +++ b/docs/docs/state/_category_.json @@ -0,0 +1,6 @@ +{ + "position": 3, + "label": "Managing State", + "collapsible": true, + "collapsed": false +} diff --git a/docs/docs/plugins/savedstate.md b/docs/docs/state/savedstate.md similarity index 98% rename from docs/docs/plugins/savedstate.md rename to docs/docs/state/savedstate.md index d363e4a9..bde40a24 100644 --- a/docs/docs/plugins/savedstate.md +++ b/docs/docs/state/savedstate.md @@ -1,5 +1,6 @@ --- -sidebar_position: 4 +sidebar_position: 2 +sidebar_label: Persisting State --- # Persist and Restore State @@ -22,7 +23,7 @@ The artifact depends on: - `androidx-lifecycle-savedstate` on Android to parcelize the state. The artifact depends on quite a few things, so it would be best to avoid adding it to all of your modules. -Instead, you can inject the plugin or savers using DI in [this guide](/plugins/debugging.md). +Instead, you can inject the plugin or savers using DI in [this guide](/misc/debugging.md). ## 2. Defining `Saver`s diff --git a/docs/docs/state/statemanagement.md b/docs/docs/state/statemanagement.md new file mode 100644 index 00000000..ef30231f --- /dev/null +++ b/docs/docs/state/statemanagement.md @@ -0,0 +1,355 @@ +--- +sidebar_position: 1 +sidebar_label: Updating State +--- + +# Managing State + +State management in FlowMVI is slightly different from what we are used to in MVVM. +This guide will teach you everything about how to manage and update application state in a safe, fast, and extensible manner. + +## Understanding State + +In FlowMVI, state is represented by classes implementing the `MVIState` marker interface. States must be: + +- **Immutable** - State object must never change after it is created +- **Comparable** - State object must implement a stable and valid `hashCode`/`equals` contract + +To adhere to the requirements above, the only thing you need to do in most cases is to use `data class`es or `data object`s that will generate `equals` and `hashCode` for you. + +The simplest state looks like this: + +```kotlin +data class CounterState( + val counter: Int = 0, + val isLoading: Boolean = false +) : MVIState +``` + +The marker interface `MVIState` is needed to **enforce** the requirements above at compilation time. If you do not define `equals`, your IDE will complain. +It also prevents you from using unintended objects (like 3rd party interfaces or network response objects) as your state. + +:::warning[Do not mutate the state directly!] + +Do not use mutable properties like `var` or mutable collections. +Always create new instances using `copy()` and ensure the collections you pass are **new** ones, not just `Mutable` collections upcasted to their parent type, +otherwise your state updates will **not be reflected**. + +::: + +## State Families + +The state in the example above can be used by itself, but most apps can be in more than one state at a time. The best example is LCE (loading-content-error) state family. +The key things we want from such state are that: + +- There are no unused/junk/placeholder values for each state. + - For example, during an `Error` state, there is no data because it failed loading, and vice versa, when the state is `Content`, we don't have an error to report. + Having these properties unnecessarily pollutes the code and fosters many bugs. +- State clients who use it cannot gain access to unwanted data. + - This means that if we pass our state to the UI to render it, we want to avoid accidentally showing **both** an error message **and** the list of items. + The only way to reliably prevent that is to **enforce** a compile-time error on clients, making them handle all different types of states. + +To achieve our goals above, FlowMVI supports **State Families**, represented as sealed interfaces: + +```kotlin +sealed interface LCEState : MVIState { + + data object Loading : LCEState + data class Error(val e: Exception) : LCEState + data class Content(val items: List) : LCEState +} +``` + +:::tip[Why an interface?] + +Using `sealed interface` instead of `class` improves performance by reducing allocations and prevents state classes from having any logic (private members). + +::: + +Now not only must the client handle all states using an exhaustive `when` block, but we no longer expose any properties that shouldn't be present, such as having `items: List? = null` or `e: Exception? = null`. + +However, the code above introduces some complexity to handling state types, such as needing to cast or check the state's type before updating it: + +```kotlin +val current = state.value as? Content ?: return +current.items // use the property +``` + +For that, the library provides a nice and simple DSL consisting of two functions: + +```kotlin +updateState { // this: LCEState.Content + copy(items = items + loadMoreItems()) +} + +withState { // this: LCEState.Error + action(ShowErrorMessage(exception = this.e)) +} +``` + +These functions first check the type of the state, and if it is not of the first type parameter, they skip the operation inside the `block` entirely. +The latter one only checks the state but does not change it. + +:::tip[Fail Fast] + +If you want to throw an exception instead of skipping the operation, there are `updateStateOrThrow` / `withStateOrThrow` functions. + +::: + +Using the functions above not only simplifies our code but also prevents various bugs due to the asynchronous nature of state updates, such as the user spamming buttons during +an animation, leading to, for example, the app retrying failed data loading multiple times. + +### Nested State Families + +Of course, you can mix and match the approaches above or introduce multiple nesting levels to your states. For example, to implement progressive content loading, you can create +a common state data class with multiple state families nested inside: + +```kotlin +sealed interface FeedState: MVIState { + data object Loading: FeedState + data class Content(val items: List): FeedState +} + +// implementing `MVIState` for nested states is not required but beneficial +sealed interface RecommendationsState: MVIState { /* ... */ } + +data class ProgressiveState( + val feed: FeedState = FeedState.Loading, + val recommended: RecommendationsState = RecommendationsState.Loading, + /* ... */ +): MVIState +``` + +In that case, you will not need the typed versions of `updateState`, but rather want to use two other functions provided by the library: + +- `value.typed()` to cast the `value` to type `T` or return `null` otherwise (just like the operator `as?`) +- `value.withType { }` to operate on `value` only if it's of type `T`, or just return it otherwise + +If you represent the state this way, you will never have to write ugly `null`-ridden code again to manage states with placeholders, +and your stores' subscribers will never have the problem of rendering an inconsistent or invalid state. + +But next, let's talk about thread safety. + +## Serialized State Transactions (SSTs) + +The key difference that FlowMVI has over conventional approaches is that state transactions (changes) are **serialized**. +This has nothing to do with JSON or networking, but rather, the term comes from the [Database Architecture](). +In simple terms: + +> Store's `state` is changed **sequentially** to prevent data races. + +Consider the following: + +```kotlin +val state = MutableStateFlow(State(items = emptyList())) + +suspend fun loadMoreItems() { + val lastIndex = state.value.items.lastIndex // (1) + val newItems = repository.requestItems(atIndex = lastIndex, amount = 20) + state.value = state.value.copy(items = items + newItems) // (2) +} +``` + +This code contains multiple race conditions: + +1. The code obtains the index to the last item to load more items, then executes a long operation, during which **the state may have already changed**. + When trying to add new items, the item list could have already been modified, and we'll get duplicate or stale values appended. This is a **data race**. +2. When mutating the state using `state.value`, while the right-hand side of the expression is being executed, the left-hand side + (`state.value`) could have already been changed by another thread, leading to the right-hand side overwriting the state with stale data. This is a **thread race**. + +The first problem arises only when we need to **read the current state** to make a decision on which logic to execute next. +In the example above, we need the current items to load the next page, but this is apparent in many other cases as well, +such as form validation, conditional logic, or LCE-based logic. + +You may have never encountered these issues before, but this is only due to luck - because the state is modified on the main thread and sequentially. +FlowMVI, unlike many other libraries, allows the state to be modified on **any thread** to enable much better performance and reactiveness of your apps. + +To address a common objection to this argument that sounds like: + +> But I can just read the state in the `update` block because it's thread-safe! + +Study the [documentation](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-mutable-state-flow/#1996476265%2FFunctions%2F1975948010) of the update function: + +> `function` may be evaluated multiple times, if `value` is being concurrently updated. + +This means that if the starting and ending states do not match, your `function` block will be executed **multiple times**. +This not only wastes resources but can be detrimental if you call a function that is not _idempotent_ (has side effects) inside the `function` block. +For example, if you are making a banking app and perform a transaction, then the code above can lead to the user's credit card being charged multiple times. +Pretty awful, don't you agree? + +Even if you always modify the state on the main thread, or use `update { }`, the parallel nature of coroutines does not prevent you from having data races. + +To combat this, the library uses SSTs by default when you gain any access to the `state`. +This is also why you can't just access `state` wherever you wish. +Instead, you only have 2 functions to gain access to the state: `updateState` and `withState`. + +The typed functions we discussed in the previous section are just aliases to the more generic ones, and they work by making sure that while the `block` is executing, +**no one else can read or change the state**. Whenever you call `updateState`, the transaction is synchronized using a Mutex, and all other code that tries to also +call `updateState` will wait in a FIFO queue until the `block` finishes execution and the state is updated. + +This means that when you have access to the state, you can be absolutely sure it will not change to something unexpected inside the lambda. +You can execute any suspending and long-running code inside the block and it will only be executed **once** and on the most up-to-date value. +So even in systems with hundreds of parallel state updates, it will always stay consistent. + +:::info + +When you use `updateState`, your store's plugins will receive both the initial and the resulting value, so that they can also respond to the update or modify it. +This does not happen when `withState` is called as the state does not change. + +::: + +### Reentrant vs Non-reentrant + +By default, SSTs are **reentrant**, which means that you are allowed to write code like this: + +```kotlin +updateState { + updateState { + + } +} +``` + +In that case, if you are already inside a state transaction, a new one will **not be created** for nested update blocks. +Otherwise, you would get a **deadlock** as the inner transaction waits for the outer state update to finish, which waits for the inner update to finish. + +This, unfortunately, has a performance penalty due to context switching, which makes the reentrant transactions ~1600% slower. +For most apps, this is negligible as the time is still measured in microseconds, so as of 3.x SSTs are reentrant by default. + +When configuring a store, it is possible to change the `stateStrategy` property to make the transactions non-reentrant: + +```kotlin +val store = store(initial = Loading) { + configure { + stateStrategy = Atomic(reentrant = false) + debuggable = true + } +} +``` + +In this case, while the store is `debuggable`, the library will check the transaction for you so that instead of a deadlock you at least get a crash. + +:::info[New Default in 4.0] + +In the future, non-reentrant transactions may become the default for the simple reason of redundancy. +Since you are in the transaction, can just use `this` property, which is already the most up-to-date state. + +::: + +### Bypassing SSTs + +Although non-reentrant transactions are already very fast, they are still ~2x slower due to locking overhead. + +Only if you absolutely **must** squeeze the last drop of performance from the store, and you are **sure** you can avoid the pitfalls discussed above manually, +you can use one of two ways to override the synchronization routines: + +- `updateStateImmediate` function, which avoids all locks, **plugins** and thread safety, or +- `StateStrategy.Immediate` which disables SSTs entirely. + +:::danger + +`updateStateImmediate` **bypasses all plugins** in addition to lacking thread safety! + +::: + +One example where that may be needed is Compose's text fields: + +```kotlin +data class State(val input: String = "") : MVIState + +// composable: +TextField( + value = state.input, + onValueChange = { intent(ChangedInput(it)) }, +) + +// container: +val store = store(State()) { + reduce { intent -> + when(intent) { + is ChangedInput -> updateStateImmediate { + copy(input = intent.value) + } + } + } +} +``` + +Due to flaws in Compose's `TextField` implementation, if you do not update the state immediately, the UI will have jank. +This will be addressed in future Compose releases with `BasicTextField2`. + +:::warning[Do not leak the state] + +Despite all the effort put into safety, it's still possible to leak the state. +For example, assigning it to external variables or launching coroutines while in an SST will **leak** the current state outside the transaction. +If you do leak the state, always assume that **any** state outside the transaction is **invalid and outdated**. + +::: + +## Reactive State Management + +With MVVM, a best practice is to produce the state from several upstream flows using `combine`, then the `stateIn` operator to make the flow hot. + +A key distinction of MVI compared to MVVM is that a BLoC always has a single, hot, mutable state. +To avoid resource leaks and redundant work, the state must only be updated **while the subscribers are present** (otherwise who is there to render the state?). +FlowMVI provides the API for that in the form of the `whileSubscribed` plugin: + +```kotlin +val store = store(ProgressiveState()) { // initial value just like stateIn + + whileSubscribed { + combine( + repo.getFeedFlow(), + repo.getRecommendationsFlow(), + ) { feed, recommendations -> + updateState { + copy( + feed = FeedState.Content(feed), + recommended = RecommendationsState.Content(recommendations), + ) + } + // don't forget to collect the flow + // highlight-next-line + }.consume(Dispatchers.Default) + } +} +``` + +So make sure that instead of collecting long-running or infinite flows in the `init` plugin, you observe data streams only when subscribers are present, unless your +internal store logic depends on the state being up-to-date in the background. + +Additionally, whenever you produce your state, such as in the `combine` lambda, you must **consider the current state**. +For example, if your state has an in-memory value, such as a text input, and you use a State Family, you must "persist" the previous value so that it is not overridden: + +```kotlin +sealed interface State : MVIState { + + data object Loading : State + data class Content( + val items: List, + val searchQuery: String = "", // in-memory value + ) : State +} + +val store = store(State.Loading) { + + whileSubscribed { + repo.getItems().collect { items -> + updateState { + State.Content( + items = items, + // highlight-next-line + searchQuery = typed()?.searchQuery ?: "" // preserve the input + ) + } + } + } +} +``` + +In the code above, we use the `typed` function to check the type of the previous state, and if it was already `Content`, we preserve the `searchQuery` value. + +The framework has no preference over whether to keep a separate flow like in MVVM, or to keep the value in the state directly, +but the state-based approach has the advantage of using SSTs and state families to achieve greater safety. + +If your concern is the boilerplate, you can extract your in-memory data into a separate `data class`, which only needs one type-check to preserve. diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts index f7d90fbe..023fcb09 100644 --- a/docs/docusaurus.config.ts +++ b/docs/docusaurus.config.ts @@ -5,7 +5,6 @@ import type * as Preset from '@docusaurus/preset-classic'; const description = "Architecture Framework for Kotlin. Reuse every line of code. Handle all errors automatically. No boilerplate. Analytics, metrics, debugging in 3 lines. 50+ features." const config: Config = { - title: 'FlowMVI', tagline: 'Simplify Complexity.', favicon: '/favicon.ico', @@ -29,6 +28,7 @@ const config: Config = { 'classic', { docs: { + breadcrumbs: false, sidebarCollapsed: false, sidebarPath: './sidebars.ts', routeBasePath: '/', @@ -62,7 +62,7 @@ const config: Config = { ], image: '/banner.png', algolia: { - contextualSearch: true, + contextualSearch: false, appId: "YFIMJHUME7", apiKey: "bf01c9fd49e108a1c013f0cfadff1322", indexName: "opensource-respawn", @@ -129,6 +129,48 @@ const config: Config = { }, ], }, + headTags: [ + { + tagName: 'link', + attributes: { + rel: 'preconnect', + href: 'https://fonts.googleapis.com', + }, + }, + { + tagName: 'link', + attributes: { + rel: 'preconnect', + href: 'https://fonts.gstatic.com', + crossorigin: 'anonymous', + }, + }, + { + tagName: 'link', + attributes: { + rel: 'preload', + href: 'https://cdn.jsdelivr.net/gh/githubnext/monaspace@v1.000/fonts/webfonts/MonaspaceNeon-Regular.woff2', + as: 'font', + type: 'font/woff2', + crossorigin: 'anonymous', + }, + }, + { + tagName: 'link', + attributes: { + rel: 'preload', + href: 'https://fonts.googleapis.com/css2?family=Montserrat+Alternates:wght@500;600;700&display=swap', + as: 'style', + }, + }, + { + tagName: 'link', + attributes: { + rel: 'stylesheet', + href: 'https://fonts.googleapis.com/css2?family=Comfortaa:wght@400;500;600;700&display=swap', + }, + } + ], } satisfies Preset.ThemeConfig, plugins: [ [ diff --git a/docs/src/css/custom.css b/docs/src/css/custom.css index 77f718e6..c12acb56 100644 --- a/docs/src/css/custom.css +++ b/docs/src/css/custom.css @@ -4,75 +4,142 @@ * work well for content-centric websites. */ -/* You can override the default Infima variables here. */ +@font-face { + font-family: "Monaspace Neon"; + src: url(https://cdn.jsdelivr.net/gh/githubnext/monaspace@v1.000/fonts/webfonts/MonaspaceNeon-Regular.woff2) + format("woff2"), + url(https://cdn.jsdelivr.net/gh/githubnext/monaspace@v1.000/fonts/webfonts/MonaspaceNeon-Regular.woff) + format("woff"); + font-display: swap; + font-feature-settings: "calt", "liga", "ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09"; +} + +@font-face { + font-family: "Montserrat Alternates"; + src: url("https://fonts.googleapis.com/css2?family=Montserrat+Alternates:wght@500;600;700&display=swap"); + font-display: swap; +} + :root { - --ifm-color-primary: #2e8555; - --ifm-color-primary-dark: #29784c; - --ifm-color-primary-darker: #277148; - --ifm-color-primary-darkest: #205d3b; - --ifm-color-primary-light: #33925d; - --ifm-color-primary-lighter: #359962; - --ifm-color-primary-lightest: #3cad6e; + --ifm-color-primary: #2e8555; + --ifm-color-primary-dark: #29784c; + --ifm-color-primary-darker: #277148; + --ifm-color-primary-darkest: #205d3b; + --ifm-color-primary-light: #33925d; + --ifm-color-primary-lighter: #359962; + --ifm-color-primary-lightest: #3cad6e; + --ifm-font-family-base: Comfortaa, Montserrat, system-ui, -apple-system, sans-serif, BlinkMacSystemFont, "Segoe UI", Helvetica, + Arial, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + --ifm-font-family-monospace: "Monaspace Neon", "JetBrains Mono", SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", "Courier New", monospace; + --ifm-font-size-base: 100%; + --ifm-code-font-size: 95%; + --ifm-heading-font-family: "Montserrat Alternates", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + Helvetica, Arial, sans-serif; + --docusaurus-highlighted-code-line-bg: #e2ecf9; } /* For readability concerns, you should choose a lighter palette in dark mode. */ -[data-theme='dark'] { - --ifm-color-primary: #00d46a; - --ifm-color-primary-dark: #00bf5f; - --ifm-color-primary-darker: #00b45a; - --ifm-color-primary-darkest: #00944a; - --ifm-color-primary-light: #00e975; - --ifm-color-primary-lighter: #00f47a; - --ifm-color-primary-lightest: #15ff8a; +[data-theme="dark"] { + --ifm-color-primary: #00d46a; + --ifm-color-primary-dark: #00bf5f; + --ifm-color-primary-darker: #00b45a; + --ifm-color-primary-darkest: #00944a; + --ifm-color-primary-light: #00e975; + --ifm-color-primary-lighter: #00f47a; + --ifm-color-primary-lightest: #15ff8a; + --docusaurus-highlighted-code-line-bg: #1E2227; } -[data-theme='light'] .DocSearch { - /* --docsearch-primary-color: var(--ifm-color-primary); */ - /* --docsearch-text-color: var(--ifm-font-color-base); */ - --docsearch-muted-color: var(--ifm-color-secondary-darkest); - --docsearch-container-background: rgba(94, 100, 112, 0.7); - /* Modal */ - --docsearch-modal-background: var(--ifm-color-secondary-lighter); - /* Search box */ - --docsearch-searchbox-background: var(--ifm-color-secondary); - --docsearch-searchbox-focus-background: var(--ifm-color-white); - /* Hit */ - --docsearch-hit-color: var(--ifm-font-color-base); - --docsearch-hit-active-color: var(--ifm-color-white); - --docsearch-hit-background: var(--ifm-color-white); - /* Footer */ - --docsearch-footer-background: var(--ifm-color-white); +[data-theme="light"] .DocSearch { + /* --docsearch-primary-color: var(--ifm-color-primary); */ + /* --docsearch-text-color: var(--ifm-font-color-base); */ + --docsearch-muted-color: var(--ifm-color-secondary-darkest); + --docsearch-container-background: rgba(94, 100, 112, 0.7); + /* Modal */ + --docsearch-modal-background: var(--ifm-color-secondary-lighter); + /* Search box */ + --docsearch-searchbox-background: var(--ifm-color-secondary); + --docsearch-searchbox-focus-background: var(--ifm-color-white); + /* Hit */ + --docsearch-hit-color: var(--ifm-font-color-base); + --docsearch-hit-active-color: var(--ifm-color-white); + --docsearch-hit-background: var(--ifm-color-white); + /* Footer */ + --docsearch-footer-background: var(--ifm-color-white); } -[data-theme='dark'] .DocSearch { - --docsearch-text-color: var(--ifm-font-color-base); - --docsearch-muted-color: var(--ifm-color-secondary-darkest); - --docsearch-container-background: rgba(47, 55, 69, 0.7); - /* Modal */ - --docsearch-modal-background: var(--ifm-background-color); - /* Search box */ - --docsearch-searchbox-background: var(--ifm-background-color); - --docsearch-searchbox-focus-background: var(--ifm-color-black); - /* Hit */ - --docsearch-hit-color: var(--ifm-font-color-base); - --docsearch-hit-active-color: var(--ifm-color-white); - --docsearch-hit-background: var(--ifm-color-emphasis-100); - /* Footer */ - --docsearch-footer-background: var(--ifm-background-surface-color); - --docsearch-key-gradient: linear-gradient( - -26.5deg, - var(--ifm-color-emphasis-200) 0%, - var(--ifm-color-emphasis-100) 100% - ); +[data-theme="dark"] .DocSearch { + --docsearch-text-color: var(--ifm-font-color-base); + --docsearch-muted-color: var(--ifm-color-secondary-darkest); + --docsearch-container-background: rgba(47, 55, 69, 0.7); + /* Modal */ + --docsearch-modal-background: var(--ifm-background-color); + /* Search box */ + --docsearch-searchbox-background: var(--ifm-background-color); + --docsearch-searchbox-focus-background: var(--ifm-color-black); + /* Hit */ + --docsearch-hit-color: var(--ifm-font-color-base); + --docsearch-hit-active-color: var(--ifm-color-white); + --docsearch-hit-background: var(--ifm-color-emphasis-100); + /* Footer */ + --docsearch-footer-background: var(--ifm-background-surface-color); + --docsearch-key-gradient: linear-gradient( + -26.5deg, + var(--ifm-color-emphasis-200) 0%, + var(--ifm-color-emphasis-100) 100% + ); } .header-github-link::before { - content: ''; - width: 24px; - height: 24px; - display: flex; - background-color: var(--ifm-navbar-link-color); - mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E"); - transition: background-color var(--ifm-transition-fast) - var(--ifm-transition-timing-default); + content: ""; + width: 24px; + height: 24px; + display: flex; + background-color: var(--ifm-navbar-link-color); + mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E"); + transition: background-color var(--ifm-transition-fast) var(--ifm-transition-timing-default); +} + +code { + font-feature-settings: "calt", "liga", "ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09"; +} + +/* Apply to headings */ +h1, +h2 { + font-family: var(--ifm-heading-font-family); + font-weight: 600; +} + +h3, +h4, +h5, +h6 { + font-family: Comfortaa; + letter-spacing: normal; +} + +h3 { + font-weight: 600; +} + +h4, +h5, +h6 { + font-weight: 500; +} + +[class*="admonitionHeading"] { + font-family: var(--ifm-font-family-base) !important; + font-size: 1rem !important; + letter-spacing: 0.01em; + font-weight: 600; + text-transform: capitalize !important; +} + +/* First letter styling for headings */ +h1::first-letter, +h2::first-letter { + color: var(--ifm-color-primary); } diff --git a/docs/static/banner.webp b/docs/static/banner.webp new file mode 100644 index 00000000..60e53595 Binary files /dev/null and b/docs/static/banner.webp differ diff --git a/scripts/update_readme.sh b/scripts/update_readme.sh new file mode 100755 index 00000000..cc6d45ea --- /dev/null +++ b/scripts/update_readme.sh @@ -0,0 +1,13 @@ +rm ./docs/docs/README.md +echo "--- +title: FlowMVI +title_meta: FlowMVI - Kotlin Architecture Framework +sidebar_label: Home +sidebar_position: 0 +hide_title: true +description: Architecture Framework for Kotlin. Reuse every line of code. Handle all errors automatically. No boilerplate. Analytics, metrics, debugging in 3 lines. 50+ features. +slug: / +--- +" > ./docs/docs/README.md + +cat ./README.md >> ./docs/docs/README.md