Skip to content

Commit

Permalink
Merge pull request #4 from ikarenkov/ui-adapter-module
Browse files Browse the repository at this point in the history
Added ui store and convenient dsl to build it
  • Loading branch information
ikarenkov authored Feb 5, 2024
2 parents 2e40291 + 2874a51 commit d331a5e
Show file tree
Hide file tree
Showing 28 changed files with 697 additions and 68 deletions.
11 changes: 0 additions & 11 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,14 +1,3 @@
buildscript {
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${libs.versions.kotlin.get()}")
}
}

plugins {
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.jvm) apply false
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package io.github.ikarenkov.kombucha

import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineName

fun DefaultStoreCoroutineExceptionHandler() = CoroutineExceptionHandler { context, throwable ->
val storeName = context[CoroutineName]
println("Unhandled error in Coroutine store named \"$storeName\".")
throwable.printStackTrace()
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
package io.github.ikarenkov.kombucha.store

import io.github.ikarenkov.kombucha.DefaultStoreCoroutineExceptionHandler
import io.github.ikarenkov.kombucha.eff_handler.EffectHandler
import io.github.ikarenkov.kombucha.reducer.Reducer
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
Expand All @@ -16,7 +15,6 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.coroutines.EmptyCoroutineContext

/**
* Basic coroutines safe implementation of [Store]. State modification is consequential with locking using [Mutex].
Expand All @@ -34,10 +32,7 @@ open class CoroutinesStore<Msg : Any, Model : Any, Eff : Any>(
private val effectHandlers: List<EffectHandler<Eff, Msg>> = listOf(),
initialState: Model,
initialEffects: Set<Eff> = setOf(),
coroutineExceptionHandler: CoroutineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
println("Unhandled error in Coroutine store named \"$name\".")
throwable.printStackTrace()
}
coroutineExceptionHandler: CoroutineExceptionHandler = DefaultStoreCoroutineExceptionHandler()
) : Store<Msg, Model, Eff> {

private val mutableState = MutableStateFlow(initialState)
Expand All @@ -49,11 +44,7 @@ open class CoroutinesStore<Msg : Any, Model : Any, Eff : Any>(
private val isCanceled: Boolean
get() = !coroutinesScope.isActive

open val coroutinesScope = CoroutineScope(
SupervisorJob() +
coroutineExceptionHandler +
(name?.let { CoroutineName(name) } ?: EmptyCoroutineContext)
)
open val coroutinesScope = StoreScope(name, coroutineExceptionHandler)

private val stateUpdateMutex = Mutex()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext

fun StoreScope(
name: String? = null,
coroutineExceptionHandler: CoroutineExceptionHandler = CoroutineExceptionHandler { _, _ -> }
coroutineExceptionHandler: CoroutineExceptionHandler = CoroutineExceptionHandler { _, _ -> },
coroutineContext: CoroutineContext = EmptyCoroutineContext
) = CoroutineScope(
SupervisorJob() +
coroutineExceptionHandler +
(name?.let { CoroutineName(name) } ?: EmptyCoroutineContext)
(name?.let { CoroutineName(name) } ?: EmptyCoroutineContext) +
coroutineContext
)
41 changes: 41 additions & 0 deletions kombucha/ui-adapter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# UI Adapter

This module contains [UiStore](/src/commonMain/kotlin/io/github/ikarenkov/kombucha/ui/UiStore.kt) that provides functionality:

1. Convert models to Ui Models
2. Cache ui effects when there is no subscribers and emit cached effects with a first subscription. It can be disable using
parameter `cacheUiEffects = false`.

You can use [UiStoreBuilder](/src/commonMain/kotlin/io/github/ikarenkov/kombucha/ui/UiStoreBuilder.kt) and function `uiBuilder` for convenient usage
without declaring all 6 generics. [UiStoreBuilder] also provides some build in functions and you can easily extend it using extension fun.

## Sample

Take a look to the [sample.feature.ui](../../sample/features/ui) for detailed examples of usage.

If your UiMsg and UiEff are subclasses of Msg and Eff, you can use following code for simple mapping only UiState

```kotlin
val store: Store<Msg, State, Eff> = ...
val uiStore = store.uiBuilder().using<Msg.Ext, UiState, Eff.Ext> { state ->
UiState(
state.itemsIds.map { resources.getString(R.string.item_title, it) }
)
}
```

Otherwise you can provide your own mappers for UiMsg -> Msg and for Eff -> UiEff

```kotlin
store.uiBuilder().using<Msg.Ext, UiState, Eff.Ext>(
uiMsgToMsgConverter = { it },
uiStateConverter = { state ->
UiState(
state.itemsIds.map { resources.getString(R.string.item_title, it) }
)
},
uiEffConverter = { eff ->
eff as? Eff.Ext
}
)
```
23 changes: 18 additions & 5 deletions kombucha/ui-adapter/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
plugins {
alias(libs.plugins.kombucha.android.library)
alias(libs.plugins.kombucha.kmp.library)
}

android.namespace = "io.github.ikarenkov.kombucha.ui_adapter"
tasks.withType<Test> {
useJUnitPlatform()
}

dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(projects.kombucha.core)
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(projects.kombucha.core)
}
commonTest.dependencies {
implementation(libs.test.kotlin)
implementation(libs.test.coroutines)
}
jvmTest.dependencies {
implementation(libs.test.junit.jupiter)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package io.github.ikarenkov.kombucha.ui

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch

/**
* Turn cold flow to hot and caches values only if there are no subscribers.
*/
@ExperimentalCoroutinesApi
fun <T> Flow<T>.cacheWhenNoSubscribers(scope: CoroutineScope): Flow<T> {
val cache = MutableSharedFlow<T>(replay = Int.MAX_VALUE)
scope.launch {
collect { cache.emit(it) }
}
return cache.onEach {
if (cache.subscriptionCount.value != 0) {
cache.resetReplayCache()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package io.github.ikarenkov.kombucha.ui

import io.github.ikarenkov.kombucha.DefaultStoreCoroutineExceptionHandler
import io.github.ikarenkov.kombucha.store.Store
import io.github.ikarenkov.kombucha.store.StoreScope
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn

/**
* Wrapper for store, that allows handle basic UI scenarios:
* 1. Convert models to Ui Models
* 2. Cache ui effects when there is no subscribers and emit cached effects with a first subscription
*/
class UiStore<UiMsg : Any, UiState : Any, UiEff : Any, Msg : Any, State : Any, Eff : Any>(
private val store: Store<Msg, State, Eff>,
private val uiMsgToMsgConverter: (UiMsg) -> Msg,
private val uiStateConverter: (State) -> UiState,
private val uiEffConverter: (Eff) -> UiEff?,
coroutineExceptionHandler: CoroutineExceptionHandler = DefaultStoreCoroutineExceptionHandler(),
uiDispatcher: CoroutineDispatcher = Dispatchers.Main,
cacheUiEffects: Boolean = true
) : Store<UiMsg, UiState, UiEff> {

private val coroutineScope = StoreScope(
name = "UiStore for $store",
coroutineExceptionHandler = coroutineExceptionHandler,
coroutineContext = uiDispatcher
)

override val state: StateFlow<UiState> = store.state
.map { uiStateConverter(it) }
.stateIn(
scope = coroutineScope,
started = SharingStarted.Lazily,
initialValue = uiStateConverter(store.state.value)
)

@OptIn(ExperimentalCoroutinesApi::class)
override val effects: Flow<UiEff> =
store.effects
.mapNotNull { uiEffConverter(it) }
.let { originalFlow ->
if (cacheUiEffects) {
originalFlow.cacheWhenNoSubscribers(coroutineScope)
} else {
originalFlow
}
}

override fun accept(msg: UiMsg) {
store.accept(uiMsgToMsgConverter(msg))
}

override fun cancel() {
coroutineScope.cancel()
store.cancel()
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package io.github.ikarenkov.kombucha.ui

import io.github.ikarenkov.kombucha.store.Store

/**
* Builder that helps to avoid explicit declaration of generic types in [UiStore] and provides different builder functions to build [UiStore].
* You can
*/
class UiStoreBuilder<Msg : Any, State : Any, Eff : Any>(
val store: Store<Msg, State, Eff>
) {

fun <UiMsg : Any, UiState : Any, UiEff : Any> using(
uiMsgToMsgConverter: (UiMsg) -> Msg,
uiStateConverter: (State) -> UiState,
uiEffConverter: (Eff) -> UiEff?,
cacheUiEffects: Boolean = true,
): UiStore<UiMsg, UiState, UiEff, Msg, State, Eff> = UiStore(
store = store,
uiMsgToMsgConverter = uiMsgToMsgConverter,
uiStateConverter = uiStateConverter,
uiEffConverter = uiEffConverter,
cacheUiEffects = cacheUiEffects
)

/**
* Implementation based on inheritance UiMsg from Msg and UiEff from Eff
*/
inline fun <UiMsg : Msg, UiState : Any, reified UiEff : Eff> using(
cacheUiEffects: Boolean = true,
noinline uiStateConverter: (State) -> UiState,
): UiStore<UiMsg, UiState, UiEff, Msg, State, Eff> = UiStore(
store = store,
uiMsgToMsgConverter = { it },
uiStateConverter = uiStateConverter,
uiEffConverter = { it as? UiEff },
cacheUiEffects = cacheUiEffects
)

}

/**
* Dsl builder that helps to avoid full declaration of generic types using regular constructor of [UiStore].
*/
fun <Msg : Any, State : Any, Eff : Any> Store<Msg, State, Eff>.uiBuilder() = UiStoreBuilder(this)
Loading

0 comments on commit d331a5e

Please sign in to comment.