This commit is contained in:
Adam Brown 2022-10-31 18:09:51 +00:00
parent 58730470f6
commit de1aa00715
15 changed files with 321 additions and 56 deletions

View File

@ -5,4 +5,5 @@ dependencies {
implementation project(":features:navigator")
implementation project(":design-library")
api project(":domains:android:core")
api project(":domains:state")
}

View File

@ -0,0 +1,49 @@
package app.dapk.st.core
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.dapk.state.Action
import app.dapk.state.ReducerFactory
import app.dapk.state.Store
import app.dapk.state.createStore
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
class StateViewModel<S, E>(
reducerFactory: ReducerFactory<S>,
eventSource: MutableSharedFlow<E>,
) : ViewModel(), StateStore<S, E> {
private val store: Store<S> = createStore(reducerFactory, viewModelScope)
override val events: SharedFlow<E> = eventSource
override val state
get() = _state!!
private var _state: S by mutableStateOf(store.getState())
init {
_state = store.getState()
store.subscribe {
_state = it
}
}
override fun dispatch(action: Action) {
viewModelScope.launch { store.dispatch(action) }
}
}
fun <S, E> createStateViewModel(block: (suspend (E) -> Unit) -> ReducerFactory<S>): StateViewModel<S, E> {
val eventSource = MutableSharedFlow<E>(extraBufferCapacity = 1)
val reducer = block { eventSource.emit(it) }
return StateViewModel(reducer, eventSource)
}
interface StateStore<S, E> {
fun dispatch(action: Action)
val events: SharedFlow<E>
val state: S
}

View File

@ -25,4 +25,3 @@ abstract class DapkViewModel<S, VE>(initialState: S, factory: MutableStateFactor
state = reducer(state)
}
}

View File

@ -0,0 +1,8 @@
plugins {
id 'kotlin'
id 'java-test-fixtures'
}
dependencies {
implementation Dependencies.mavenCentral.kotlinCoroutinesCore
}

View File

@ -0,0 +1,148 @@
package app.dapk.state
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.reflect.KClass
fun <S> createStore(reducerFactory: ReducerFactory<S>, coroutineScope: CoroutineScope): Store<S> {
val subscribers = mutableListOf<(S) -> Unit>()
var state: S = reducerFactory.initialState()
return object : Store<S> {
private val scope = createScope(coroutineScope, this)
private val reducer = reducerFactory.create(scope)
override suspend fun dispatch(action: Action) {
scope.coroutineScope.launch {
state = reducer.reduce(action).also { nextState ->
println("!!! next state: $nextState")
subscribers.forEach { it.invoke(nextState) }
}
}
}
override fun getState() = state
override fun subscribe(subscriber: (S) -> Unit) {
subscribers.add(subscriber)
}
}
}
interface ReducerFactory<S> {
fun create(scope: ReducerScope<S>): Reducer<S>
fun initialState(): S
}
fun interface Reducer<S> {
suspend fun reduce(action: Action): S
}
private fun <S> createScope(coroutineScope: CoroutineScope, store: Store<S>) = object : ReducerScope<S> {
override val coroutineScope = coroutineScope
override suspend fun dispatch(action: Action) = store.dispatch(action)
override fun getState(): S = store.getState()
}
interface Store<S> {
suspend fun dispatch(action: Action)
fun getState(): S
fun subscribe(subscriber: (S) -> Unit)
}
interface ReducerScope<S> {
val coroutineScope: CoroutineScope
suspend fun dispatch(action: Action)
fun getState(): S
}
sealed interface ActionHandler<S> {
val key: KClass<Action>
class Async<S>(override val key: KClass<Action>, val handler: suspend ReducerScope<S>.(Action) -> Unit) : ActionHandler<S>
class Sync<S>(override val key: KClass<Action>, val handler: (Action, S) -> S) : ActionHandler<S>
class Delegate<S>(override val key: KClass<Action>, val handler: ReducerScope<S>.(Action) -> ActionHandler<S>) : ActionHandler<S>
}
fun <S> createReducer(
initialState: S,
vararg reducers: (ReducerScope<S>) -> ActionHandler<S>,
): ReducerFactory<S> {
return object : ReducerFactory<S> {
override fun create(scope: ReducerScope<S>): Reducer<S> {
val reducersMap = reducers
.map { it.invoke(scope) }
.groupBy { it.key }
return Reducer { action ->
val result = reducersMap.keys
.filter { it.java.isAssignableFrom(action::class.java) }
.fold(scope.getState() ?: initialState) { acc, key ->
val actionHandlers = reducersMap[key]!!
actionHandlers.fold(acc) { acc, handler ->
when (handler) {
is ActionHandler.Async -> {
handler.handler.invoke(scope, action)
acc
}
is ActionHandler.Sync -> handler.handler.invoke(action, acc)
is ActionHandler.Delegate -> when (val next = handler.handler.invoke(scope, action)) {
is ActionHandler.Async -> {
next.handler.invoke(scope, action)
acc
}
is ActionHandler.Sync -> next.handler.invoke(action, acc)
is ActionHandler.Delegate -> error("is not possible")
}
}
}
}
result
}
}
override fun initialState(): S = initialState
}
}
fun <A : Action, S> sideEffect(klass: KClass<A>, block: suspend (A, S) -> Unit): (ReducerScope<S>) -> ActionHandler<S> {
return {
ActionHandler.Async(key = klass as KClass<Action>) { action -> block(action as A, getState()) }
}
}
fun <A : Action, S> change(klass: KClass<A>, block: (A, S) -> S): (ReducerScope<S>) -> ActionHandler<S> {
return {
ActionHandler.Sync(key = klass as KClass<Action>, block as (Action, S) -> S)
}
}
fun <A : Action, S> async(klass: KClass<A>, block: suspend ReducerScope<S>.(A) -> Unit): (ReducerScope<S>) -> ActionHandler<S> {
return {
ActionHandler.Async(key = klass as KClass<Action>, block as suspend ReducerScope<S>.(Action) -> Unit)
}
}
fun <A : Action, S> multi(klass: KClass<A>, block: Multi<A, S>.(A) -> (ReducerScope<S>) -> ActionHandler<S>): (ReducerScope<S>) -> ActionHandler<S> {
val multiScope = object : Multi<A, S> {
override fun sideEffect(block: (A, S) -> Unit): (ReducerScope<S>) -> ActionHandler<S> = sideEffect(klass, block)
override fun change(block: (A, S) -> S): (ReducerScope<S>) -> ActionHandler<S> = change(klass, block)
override fun async(block: suspend ReducerScope<S>.(A) -> Unit): (ReducerScope<S>) -> ActionHandler<S> = async(klass, block)
}
return {
ActionHandler.Delegate(key = klass as KClass<Action>) { action ->
block(multiScope, action as A).invoke(this)
}
}
}
interface Multi<A : Action, S> {
fun sideEffect(block: (A, S) -> Unit): (ReducerScope<S>) -> ActionHandler<S>
fun change(block: (A, S) -> S): (ReducerScope<S>) -> ActionHandler<S>
fun async(block: suspend ReducerScope<S>.(A) -> Unit): (ReducerScope<S>) -> ActionHandler<S>
}
interface Action

View File

@ -4,6 +4,7 @@ dependencies {
implementation project(":chat-engine")
implementation project(":domains:android:compose-core")
implementation project(":domains:android:viewmodel")
implementation project(":domains:state")
implementation project(":features:messenger")
implementation project(":core")
implementation project(":design-library")

View File

@ -68,8 +68,8 @@ fun DirectoryScreen(directoryViewModel: DirectoryViewModel) {
directoryViewModel.ObserveEvents(listState, toolbarOffsetHeightPx)
LifecycleEffect(
onStart = { directoryViewModel.start() },
onStop = { directoryViewModel.stop() }
onStart = { directoryViewModel.dispatch(ComponentLifecycle.OnVisible) },
onStop = { directoryViewModel.dispatch(ComponentLifecycle.OnGone) }
)
val nestedScrollConnection = remember {

View File

@ -2,6 +2,8 @@ package app.dapk.st.directory
import android.content.Context
import app.dapk.st.core.ProvidableModule
import app.dapk.st.core.StateViewModel
import app.dapk.st.core.createStateViewModel
import app.dapk.st.engine.ChatEngine
class DirectoryModule(
@ -9,10 +11,7 @@ class DirectoryModule(
private val chatEngine: ChatEngine,
) : ProvidableModule {
fun directoryViewModel(): DirectoryViewModel {
return DirectoryViewModel(
ShortcutHandler(context),
chatEngine,
)
fun directoryViewModel(): StateViewModel<DirectoryScreenState, DirectoryEvent> {
return createStateViewModel { directoryReducer(chatEngine, ShortcutHandler(context), it) }
}
}
}

View File

@ -0,0 +1,60 @@
package app.dapk.st.directory
import app.dapk.st.core.StateStore
import app.dapk.st.engine.ChatEngine
import app.dapk.st.engine.DirectoryState
import app.dapk.state.*
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
typealias DirectoryViewModel = StateStore<DirectoryScreenState, DirectoryEvent>
fun directoryReducer(
chatEngine: ChatEngine,
shortcutHandler: ShortcutHandler,
eventEmitter: suspend (DirectoryEvent) -> Unit,
): ReducerFactory<DirectoryScreenState> {
var syncJob: Job? = null
return createReducer(
initialState = DirectoryScreenState.EmptyLoading,
multi(ComponentLifecycle::class) { action ->
when (action) {
ComponentLifecycle.OnVisible -> async { _ ->
syncJob = chatEngine.directory().onEach {
shortcutHandler.onDirectoryUpdate(it.map { it.overview })
when (it.isEmpty()) {
true -> dispatch(DirectoryStateChange.Empty)
false -> dispatch(DirectoryStateChange.Content(it))
}
}.launchIn(coroutineScope)
}
ComponentLifecycle.OnGone -> sideEffect { _, _ -> syncJob?.cancel() }
}
},
change(DirectoryStateChange::class) { action, _ ->
when (action) {
is DirectoryStateChange.Content -> DirectoryScreenState.Content(action.content)
DirectoryStateChange.Empty -> DirectoryScreenState.Empty
}
},
sideEffect(DirectorySideEffect.ScrollToTop::class) { _, _ ->
eventEmitter(DirectoryEvent.ScrollToTop)
}
)
}
sealed interface ComponentLifecycle : Action {
object OnVisible : ComponentLifecycle
object OnGone : ComponentLifecycle
}
sealed interface DirectorySideEffect : Action {
object ScrollToTop : DirectorySideEffect
}
sealed interface DirectoryStateChange : Action {
object Empty : DirectoryStateChange
data class Content(val content: DirectoryState) : DirectoryStateChange
}

View File

@ -1,45 +0,0 @@
package app.dapk.st.directory
import androidx.lifecycle.viewModelScope
import app.dapk.st.directory.DirectoryScreenState.*
import app.dapk.st.engine.ChatEngine
import app.dapk.st.viewmodel.DapkViewModel
import app.dapk.st.viewmodel.MutableStateFactory
import app.dapk.st.viewmodel.defaultStateFactory
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
class DirectoryViewModel(
private val shortcutHandler: ShortcutHandler,
private val chatEngine: ChatEngine,
factory: MutableStateFactory<DirectoryScreenState> = defaultStateFactory(),
) : DapkViewModel<DirectoryScreenState, DirectoryEvent>(
initialState = EmptyLoading,
factory,
) {
private var syncJob: Job? = null
fun start() {
syncJob = viewModelScope.launch {
chatEngine.directory().onEach {
shortcutHandler.onDirectoryUpdate(it.map { it.overview })
state = when (it.isEmpty()) {
true -> Empty
false -> Content(it)
}
}.collect()
}
}
fun stop() {
syncJob?.cancel()
}
fun scrollToTopOfMessages() {
_events.tryEmit(DirectoryEvent.ScrollToTop)
}
}

View File

@ -9,6 +9,7 @@ dependencies {
implementation project(":domains:android:compose-core")
implementation project(":domains:android:viewmodel")
implementation project(':domains:store')
implementation project(':domains:state')
implementation project(":core")
implementation project(":design-library")
implementation Dependencies.mavenCentral.coil

View File

@ -1,6 +1,7 @@
package app.dapk.st.home
import androidx.lifecycle.viewModelScope
import app.dapk.st.directory.DirectorySideEffect
import app.dapk.st.directory.DirectoryViewModel
import app.dapk.st.domain.StoreCleaner
import app.dapk.st.engine.ChatEngine
@ -92,7 +93,7 @@ class HomeViewModel(
}
fun scrollToTopOfMessages() {
directoryViewModel.scrollToTopOfMessages()
directoryViewModel.dispatch(DirectorySideEffect.ScrollToTop)
}
fun changePage(page: Page) {

View File

@ -15,6 +15,7 @@ import app.dapk.st.core.DapkActivity
import app.dapk.st.core.module
import app.dapk.st.core.viewModel
import app.dapk.st.directory.DirectoryModule
import app.dapk.st.directory.DirectoryViewModel
import app.dapk.st.login.LoginModule
import app.dapk.st.profile.ProfileModule
import kotlinx.coroutines.flow.launchIn
@ -22,7 +23,7 @@ import kotlinx.coroutines.flow.onEach
class MainActivity : DapkActivity() {
private val directoryViewModel by viewModel { module<DirectoryModule>().directoryViewModel() }
private val directoryViewModel: DirectoryViewModel by viewModel { module<DirectoryModule>().directoryViewModel() }
private val loginViewModel by viewModel { module<LoginModule>().loginViewModel() }
private val profileViewModel by viewModel { module<ProfileModule>().profileViewModel() }
private val homeViewModel by viewModel { module<HomeModule>().homeViewModel(directoryViewModel, loginViewModel, profileViewModel) }

View File

@ -0,0 +1,41 @@
package app.dapk.st.messenger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.Test
class ImplBarTest {
@Test
fun `test bar`() {
val viewModelScope = CoroutineScope(UnconfinedTestDispatcher())
val reducer = FooBar.createReducer(
initialState = ImplBar.BankState(amount = 0),
FooBar.sideEffect(ImplBar.BankAction::class) { action, state ->
println("SE ${action::class.simpleName} $state")
},
FooBar.sideEffect(ImplBar.BankAction.Foo::class) { action, state ->
println("FOO - $action $state")
},
FooBar.change(ImplBar.BankAction.Increment::class) { _, state ->
state.copy(amount = state.amount + 1)
},
FooBar.async(ImplBar.BankAction.MultiInc::class) { _ ->
flowOf(0, 1, 2)
.onEach { dispatch(ImplBar.BankAction.Increment) }
.launchIn(coroutineScope)
},
)
val store = FooBar.createStore(reducer, viewModelScope)
store.subscribe {
println(it)
}
runBlocking { store.dispatch(ImplBar.BankAction.Inc) }
}
}

View File

@ -33,6 +33,7 @@ include ':domains:android:viewmodel'
include ':domains:store'
include ':domains:olm-stub'
include ':domains:olm'
include ':domains:state'
include ':domains:firebase:crashlytics'
include ':domains:firebase:crashlytics-noop'