Convert home view model to reducer

This commit is contained in:
Adam Brown 2022-12-30 13:24:05 +00:00
parent c6ad944994
commit 7a8c8ed88d
14 changed files with 240 additions and 195 deletions

View File

@ -67,7 +67,7 @@ class SmallTalkApplication : Application(), ModuleProvider {
} }
} }
@Suppress("UNCHECKED_CAST", "IMPLICIT_CAST_TO_ANY") @Suppress("UNCHECKED_CAST")
override fun <T : ProvidableModule> provide(klass: KClass<T>): T { override fun <T : ProvidableModule> provide(klass: KClass<T>): T {
return when (klass) { return when (klass) {
DirectoryModule::class -> featureModules.directoryModule DirectoryModule::class -> featureModules.directoryModule

View File

@ -190,6 +190,9 @@ internal class FeatureModules internal constructor(
storeModule.value.applicationStore(), storeModule.value.applicationStore(),
buildMeta, buildMeta,
), ),
profileModule,
loginModule,
directoryModule
) )
} }
val settingsModule by unsafeLazy { val settingsModule by unsafeLazy {

View File

@ -1,12 +1,13 @@
package app.dapk.st.directory package app.dapk.st.directory
import android.content.Context import android.content.Context
import app.dapk.st.core.ProvidableModule
import app.dapk.st.state.createStateViewModel
import app.dapk.st.core.JobBag import app.dapk.st.core.JobBag
import app.dapk.st.core.ProvidableModule
import app.dapk.st.directory.state.DirectoryEvent
import app.dapk.st.directory.state.DirectoryState import app.dapk.st.directory.state.DirectoryState
import app.dapk.st.directory.state.directoryReducer import app.dapk.st.directory.state.directoryReducer
import app.dapk.st.engine.ChatEngine import app.dapk.st.engine.ChatEngine
import app.dapk.st.state.createStateViewModel
class DirectoryModule( class DirectoryModule(
private val context: Context, private val context: Context,
@ -14,6 +15,8 @@ class DirectoryModule(
) : ProvidableModule { ) : ProvidableModule {
fun directoryState(): DirectoryState { fun directoryState(): DirectoryState {
return createStateViewModel { directoryReducer(chatEngine, ShortcutHandler(context), JobBag(), it) } return createStateViewModel { directoryReducer(it) }
} }
fun directoryReducer(eventEmitter: suspend (DirectoryEvent) -> Unit) = directoryReducer(chatEngine, ShortcutHandler(context), JobBag(), eventEmitter)
} }

View File

@ -13,7 +13,6 @@ dependencies {
implementation project(":features:settings") implementation project(":features:settings")
implementation project(":features:profile") implementation project(":features:profile")
implementation project(":domains:android:compose-core") implementation project(":domains:android:compose-core")
implementation project(":domains:android:viewmodel")
implementation project(':domains:store') implementation project(':domains:store')
implementation 'screen-state:screen-android' implementation 'screen-state:screen-android'
implementation project(":core") implementation project(":core")

View File

@ -1,27 +1,51 @@
package app.dapk.st.home package app.dapk.st.home
import app.dapk.st.core.JobBag
import app.dapk.st.core.ProvidableModule import app.dapk.st.core.ProvidableModule
import app.dapk.st.directory.state.DirectoryState import app.dapk.st.directory.DirectoryModule
import app.dapk.st.domain.StoreModule import app.dapk.st.domain.StoreModule
import app.dapk.st.engine.ChatEngine import app.dapk.st.engine.ChatEngine
import app.dapk.st.login.state.LoginState import app.dapk.st.home.state.createHomeReducer
import app.dapk.st.profile.state.ProfileState import app.dapk.st.login.LoginModule
import app.dapk.st.profile.ProfileModule
import app.dapk.st.state.State
import app.dapk.st.state.createStateViewModel
import app.dapk.state.Action
import app.dapk.state.DynamicReducers
import app.dapk.state.combineReducers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterIsInstance
class HomeModule( class HomeModule(
private val chatEngine: ChatEngine, private val chatEngine: ChatEngine,
private val storeModule: StoreModule, private val storeModule: StoreModule,
val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase, val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase,
private val profileModule: ProfileModule,
private val loginModule: LoginModule,
private val directoryModule: DirectoryModule,
) : ProvidableModule { ) : ProvidableModule {
internal fun homeViewModel(directory: DirectoryState, login: LoginState, profile: ProfileState): HomeViewModel { internal fun compositeHomeState(): DynamicState {
return HomeViewModel( return createStateViewModel {
chatEngine, combineReducers(
directory, listOf(
login, homeReducerFactory(it),
profile, loginModule.loginReducer(it),
storeModule.cacheCleaner(), profileModule.profileReducer(),
betaVersionUpgradeUseCase, directoryModule.directoryReducer(it)
) )
)
}
} }
} private fun homeReducerFactory(eventEmitter: suspend (Any) -> Unit) =
createHomeReducer(chatEngine, storeModule.cacheCleaner(), betaVersionUpgradeUseCase, JobBag(), eventEmitter)
}
typealias DynamicState = State<DynamicReducers, Any>
inline fun <reified S, reified E> DynamicState.childState() = object : State<S, E> {
override fun dispatch(action: Action) = this@childState.dispatch(action)
override val events: Flow<E> = this@childState.events.filterIsInstance()
override val current: S = this@childState.current.getState()
}

View File

@ -11,9 +11,11 @@ import app.dapk.st.core.components.CenteredLoading
import app.dapk.st.design.components.CircleishAvatar import app.dapk.st.design.components.CircleishAvatar
import app.dapk.st.directory.DirectoryScreen import app.dapk.st.directory.DirectoryScreen
import app.dapk.st.directory.state.DirectoryState import app.dapk.st.directory.state.DirectoryState
import app.dapk.st.home.HomeScreenState.* import app.dapk.st.home.state.HomeAction
import app.dapk.st.home.HomeScreenState.Page.Directory import app.dapk.st.home.state.HomeScreenState.*
import app.dapk.st.home.HomeScreenState.Page.Profile import app.dapk.st.home.state.HomeScreenState.Page.Directory
import app.dapk.st.home.state.HomeScreenState.Page.Profile
import app.dapk.st.home.state.HomeState
import app.dapk.st.login.LoginScreen import app.dapk.st.login.LoginScreen
import app.dapk.st.login.state.LoginState import app.dapk.st.login.state.LoginState
import app.dapk.st.profile.ProfileScreen import app.dapk.st.profile.ProfileScreen
@ -21,18 +23,18 @@ import app.dapk.st.profile.state.ProfileState
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
internal fun HomeScreen(homeViewModel: HomeViewModel, directoryState: DirectoryState, loginState: LoginState, profileState: ProfileState) { internal fun HomeScreen(homeState: HomeState, directoryState: DirectoryState, loginState: LoginState, profileState: ProfileState) {
LifecycleEffect( LifecycleEffect(
onStart = { homeViewModel.start() }, onStart = { homeState.dispatch(HomeAction.LifecycleVisible) },
onStop = { homeViewModel.stop() } onStop = { homeState.dispatch(HomeAction.LifecycleGone) }
) )
when (val state = homeViewModel.state) { when (val state = homeState.current) {
Loading -> CenteredLoading() Loading -> CenteredLoading()
is SignedIn -> { is SignedIn -> {
Scaffold( Scaffold(
bottomBar = { bottomBar = {
BottomBar(state, homeViewModel) BottomBar(state, homeState)
}, },
content = { innerPadding -> content = { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) { Box(modifier = Modifier.padding(innerPadding)) {
@ -40,7 +42,7 @@ internal fun HomeScreen(homeViewModel: HomeViewModel, directoryState: DirectoryS
Directory -> DirectoryScreen(directoryState) Directory -> DirectoryScreen(directoryState)
Profile -> { Profile -> {
ProfileScreen(profileState) { ProfileScreen(profileState) {
homeViewModel.changePage(Directory) homeState.dispatch(HomeAction.ChangePage(Directory))
} }
} }
} }
@ -51,7 +53,7 @@ internal fun HomeScreen(homeViewModel: HomeViewModel, directoryState: DirectoryS
SignedOut -> { SignedOut -> {
LoginScreen(loginState) { LoginScreen(loginState) {
homeViewModel.loggedIn() homeState.dispatch(HomeAction.LoggedIn)
} }
} }
} }
@ -59,7 +61,7 @@ internal fun HomeScreen(homeViewModel: HomeViewModel, directoryState: DirectoryS
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun BottomBar(state: SignedIn, homeViewModel: HomeViewModel) { private fun BottomBar(state: SignedIn, homeState: HomeState) {
Column { Column {
Divider(modifier = Modifier.fillMaxWidth(), color = Color.Black.copy(alpha = 0.2f), thickness = 0.5.dp) Divider(modifier = Modifier.fillMaxWidth(), color = Color.Black.copy(alpha = 0.2f), thickness = 0.5.dp)
NavigationBar(containerColor = Color.Transparent, modifier = Modifier.height(IntrinsicSize.Min)) { NavigationBar(containerColor = Color.Transparent, modifier = Modifier.height(IntrinsicSize.Min)) {
@ -70,8 +72,8 @@ private fun BottomBar(state: SignedIn, homeViewModel: HomeViewModel) {
selected = state.page == page, selected = state.page == page,
onClick = { onClick = {
when { when {
state.page == page -> homeViewModel.scrollToTopOfMessages() state.page == page -> homeState.dispatch(HomeAction.ScrollToTop)
else -> homeViewModel.changePage(page) else -> homeState.dispatch(HomeAction.ChangePage(page))
} }
}, },
) )
@ -89,7 +91,7 @@ private fun BottomBar(state: SignedIn, homeViewModel: HomeViewModel) {
} }
}, },
selected = state.page == page, selected = state.page == page,
onClick = { homeViewModel.changePage(page) }, onClick = { homeState.dispatch(HomeAction.ChangePage(page)) },
) )
} }
} }

View File

@ -1,127 +0,0 @@
package app.dapk.st.home
import androidx.lifecycle.viewModelScope
import app.dapk.st.directory.state.ComponentLifecycle
import app.dapk.st.directory.state.DirectorySideEffect
import app.dapk.st.directory.state.DirectoryState
import app.dapk.st.domain.StoreCleaner
import app.dapk.st.engine.ChatEngine
import app.dapk.st.home.HomeScreenState.*
import app.dapk.st.login.state.LoginState
import app.dapk.st.profile.state.ProfileAction
import app.dapk.st.profile.state.ProfileState
import app.dapk.st.viewmodel.DapkViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
internal class HomeViewModel(
private val chatEngine: ChatEngine,
private val directoryState: DirectoryState,
private val loginState: LoginState,
private val profileState: ProfileState,
private val cacheCleaner: StoreCleaner,
private val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase,
) : DapkViewModel<HomeScreenState, HomeEvent>(
initialState = Loading
) {
private var listenForInvitesJob: Job? = null
fun start() {
viewModelScope.launch {
state = if (chatEngine.isSignedIn()) {
_events.emit(HomeEvent.OnShowContent)
initialHomeContent()
} else {
SignedOut
}
}
viewModelScope.launch {
if (chatEngine.isSignedIn()) {
listenForInviteChanges()
}
}
}
private suspend fun initialHomeContent(): SignedIn {
val me = chatEngine.me(forceRefresh = false)
return when (val current = state) {
Loading -> SignedIn(Page.Directory, me, invites = 0)
is SignedIn -> current.copy(me = me, invites = current.invites)
SignedOut -> SignedIn(Page.Directory, me, invites = 0)
}
}
fun loggedIn() {
viewModelScope.launch {
state = initialHomeContent()
_events.emit(HomeEvent.OnShowContent)
listenForInviteChanges()
}
}
private fun CoroutineScope.listenForInviteChanges() {
listenForInvitesJob?.cancel()
listenForInvitesJob = chatEngine.invites()
.onEach { invites ->
when (val currentState = state) {
is SignedIn -> updateState { currentState.copy(invites = invites.size) }
Loading,
SignedOut -> {
// do nothing
}
}
}.launchIn(this)
}
fun hasVersionChanged() = betaVersionUpgradeUseCase.hasVersionChanged()
fun clearCache() {
viewModelScope.launch {
cacheCleaner.cleanCache(removeCredentials = false)
betaVersionUpgradeUseCase.notifyUpgraded()
_events.emit(HomeEvent.Relaunch)
}
}
fun scrollToTopOfMessages() {
directoryState.dispatch(DirectorySideEffect.ScrollToTop)
}
fun changePage(page: Page) {
state = when (val current = state) {
Loading -> current
is SignedIn -> {
when (page) {
current.page -> current
else -> current.copy(page = page).also {
pageChangeSideEffects(page)
}
}
}
SignedOut -> current
}
}
private fun pageChangeSideEffects(page: Page) {
when (page) {
Page.Directory -> {
// do nothing
}
Page.Profile -> {
directoryState.dispatch(ComponentLifecycle.OnGone)
profileState.dispatch(ProfileAction.Reset)
}
}
}
fun stop() {
// do nothing
}
}

View File

@ -12,26 +12,24 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import app.dapk.st.core.DapkActivity import app.dapk.st.core.DapkActivity
import app.dapk.st.core.extensions.unsafeLazy
import app.dapk.st.core.module import app.dapk.st.core.module
import app.dapk.st.core.viewModel import app.dapk.st.home.state.HomeAction
import app.dapk.st.directory.DirectoryModule import app.dapk.st.home.state.HomeEvent
import app.dapk.st.login.LoginModule import app.dapk.st.home.state.HomeState
import app.dapk.st.profile.ProfileModule
import app.dapk.st.state.state import app.dapk.st.state.state
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
class MainActivity : DapkActivity() { class MainActivity : DapkActivity() {
private val directoryState by state { module<DirectoryModule>().directoryState() } private val homeModule by unsafeLazy { module<HomeModule>() }
private val loginState by state { module<LoginModule>().loginState() } private val compositeState by state { homeModule.compositeHomeState() }
private val profileState by state { module<ProfileModule>().profileState() }
private val homeViewModel by viewModel { module<HomeModule>().homeViewModel(directoryState, loginState, profileState) }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val pushPermissionLauncher = registerPushPermission() val pushPermissionLauncher = registerPushPermission()
homeViewModel.events.onEach { compositeState.events.onEach {
when (it) { when (it) {
HomeEvent.Relaunch -> recreate() HomeEvent.Relaunch -> recreate()
HomeEvent.OnShowContent -> pushPermissionLauncher?.invoke() HomeEvent.OnShowContent -> pushPermissionLauncher?.invoke()
@ -39,11 +37,12 @@ class MainActivity : DapkActivity() {
}.launchIn(lifecycleScope) }.launchIn(lifecycleScope)
setContent { setContent {
if (homeViewModel.hasVersionChanged()) { val homeState: HomeState = compositeState.childState()
BetaUpgradeDialog() if (homeModule.betaVersionUpgradeUseCase.hasVersionChanged()) {
BetaUpgradeDialog(homeState)
} else { } else {
Surface(Modifier.fillMaxSize()) { Surface(Modifier.fillMaxSize()) {
HomeScreen(homeViewModel, directoryState, loginState, profileState) HomeScreen(homeState, compositeState.childState(), compositeState.childState(), compositeState.childState())
} }
} }
} }
@ -56,20 +55,20 @@ class MainActivity : DapkActivity() {
null null
} }
} }
}
@Composable
private fun BetaUpgradeDialog() { @Composable
AlertDialog( private fun BetaUpgradeDialog(homeState: HomeState) {
title = { Text(text = "BETA") }, AlertDialog(
text = { Text(text = "During the BETA, version upgrades require a cache clear") }, title = { Text(text = "BETA") },
onDismissRequest = { text = { Text(text = "During the BETA, version upgrades require a cache clear") },
onDismissRequest = {
},
confirmButton = { },
TextButton(onClick = { homeViewModel.clearCache() }) { confirmButton = {
Text(text = "Clear cache".uppercase()) TextButton(onClick = { homeState.dispatch(HomeAction.ClearCache) }) {
} Text(text = "Clear cache".uppercase())
}, }
) },
} )
} }

View File

@ -0,0 +1,116 @@
package app.dapk.st.home.state
import app.dapk.st.core.JobBag
import app.dapk.st.directory.state.ComponentLifecycle
import app.dapk.st.directory.state.DirectorySideEffect
import app.dapk.st.domain.StoreCleaner
import app.dapk.st.engine.ChatEngine
import app.dapk.st.home.BetaVersionUpgradeUseCase
import app.dapk.st.profile.state.ProfileAction
import app.dapk.state.*
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
fun createHomeReducer(
chatEngine: ChatEngine,
cacheCleaner: StoreCleaner,
betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase,
jobBag: JobBag,
eventEmitter: suspend (HomeEvent) -> Unit,
): ReducerFactory<HomeScreenState> {
return createReducer(
initialState = HomeScreenState.Loading,
change(HomeAction.UpdateState::class) { action, _ ->
action.state
},
change(HomeAction.UpdateInvitesCount::class) { action, state ->
when (state) {
HomeScreenState.Loading -> state
is HomeScreenState.SignedIn -> state.copy(invites = action.invitesCount)
HomeScreenState.SignedOut -> state
}
},
async(HomeAction.LifecycleVisible::class) { _ ->
if (chatEngine.isSignedIn()) {
eventEmitter.invoke(HomeEvent.OnShowContent)
dispatch(HomeAction.InitialHome)
listenForInviteChanges(chatEngine, jobBag)
} else {
dispatch(HomeAction.UpdateState(HomeScreenState.SignedOut))
}
},
async(HomeAction.InitialHome::class) {
val me = chatEngine.me(forceRefresh = false)
val nextState = when (val current = getState()) {
HomeScreenState.Loading -> HomeScreenState.SignedIn(HomeScreenState.Page.Directory, me, invites = 0)
is HomeScreenState.SignedIn -> current.copy(me = me, invites = current.invites)
HomeScreenState.SignedOut -> HomeScreenState.SignedIn(HomeScreenState.Page.Directory, me, invites = 0)
}
dispatch(HomeAction.UpdateState(nextState))
},
async(HomeAction.LoggedIn::class) {
dispatch(HomeAction.InitialHome)
eventEmitter.invoke(HomeEvent.OnShowContent)
listenForInviteChanges(chatEngine, jobBag)
},
multi(HomeAction.ChangePage::class) { action ->
change { _, state ->
when (state) {
is HomeScreenState.SignedIn -> when (action.page) {
state.page -> state
else -> state.copy(page = action.page).also {
async {
when (action.page) {
HomeScreenState.Page.Directory -> {
// do nothing
}
HomeScreenState.Page.Profile -> {
dispatch(ComponentLifecycle.OnGone)
dispatch(ProfileAction.Reset)
}
}
}
}
}
HomeScreenState.Loading -> state
HomeScreenState.SignedOut -> state
}
}
},
async(HomeAction.ScrollToTop::class) {
dispatch(DirectorySideEffect.ScrollToTop)
},
sideEffect(HomeAction.ClearCache::class) { _, _ ->
cacheCleaner.cleanCache(removeCredentials = false)
betaVersionUpgradeUseCase.notifyUpgraded()
eventEmitter.invoke(HomeEvent.Relaunch)
}
)
}
private fun ReducerScope<HomeScreenState>.listenForInviteChanges(chatEngine: ChatEngine, jobBag: JobBag) {
jobBag.replace(
"invites-count",
chatEngine.invites()
.onEach { invites ->
when (getState()) {
is HomeScreenState.SignedIn -> dispatch(HomeAction.UpdateInvitesCount(invites.size))
HomeScreenState.Loading,
HomeScreenState.SignedOut -> {
// do nothing
}
}
}.launchIn(coroutineScope)
)
}

View File

@ -0,0 +1,18 @@
package app.dapk.st.home.state
import app.dapk.st.home.state.HomeScreenState.Page
import app.dapk.state.Action
sealed interface HomeAction : Action {
object LifecycleVisible : HomeAction
object LifecycleGone : HomeAction
object ScrollToTop : HomeAction
object ClearCache : HomeAction
object LoggedIn : HomeAction
data class ChangePage(val page: Page) : HomeAction
data class UpdateInvitesCount(val invitesCount: Int) : HomeAction
data class UpdateState(val state: HomeScreenState) : HomeAction
object InitialHome : HomeAction
}

View File

@ -1,10 +1,13 @@
package app.dapk.st.home package app.dapk.st.home.state
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import app.dapk.st.engine.Me import app.dapk.st.engine.Me
import app.dapk.st.state.State
typealias HomeState = State<HomeScreenState, HomeEvent>
sealed interface HomeScreenState { sealed interface HomeScreenState {

View File

@ -3,11 +3,10 @@ package app.dapk.st.login
import app.dapk.st.core.ProvidableModule import app.dapk.st.core.ProvidableModule
import app.dapk.st.core.extensions.ErrorTracker import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.engine.ChatEngine import app.dapk.st.engine.ChatEngine
import app.dapk.st.login.state.LoginState import app.dapk.st.login.state.*
import app.dapk.st.login.state.LoginUseCase
import app.dapk.st.login.state.loginReducer
import app.dapk.st.push.PushModule import app.dapk.st.push.PushModule
import app.dapk.st.state.createStateViewModel import app.dapk.st.state.createStateViewModel
import app.dapk.state.ReducerFactory
class LoginModule( class LoginModule(
private val chatEngine: ChatEngine, private val chatEngine: ChatEngine,
@ -17,8 +16,12 @@ class LoginModule(
fun loginState(): LoginState { fun loginState(): LoginState {
return createStateViewModel { return createStateViewModel {
val loginUseCase = LoginUseCase(chatEngine, pushModule.pushTokenRegistrars(), errorTracker) loginReducer(it)
loginReducer(loginUseCase, it)
} }
} }
fun loginReducer(eventEmitter: suspend (LoginEvent) -> Unit): ReducerFactory<LoginScreenState> {
val loginUseCase = LoginUseCase(chatEngine, pushModule.pushTokenRegistrars(), errorTracker)
return loginReducer(loginUseCase, eventEmitter)
}
} }

View File

@ -15,7 +15,9 @@ class ProfileModule(
) : ProvidableModule { ) : ProvidableModule {
fun profileState(): ProfileState { fun profileState(): ProfileState {
return createStateViewModel { profileReducer(chatEngine, errorTracker, ProfileUseCase(chatEngine, errorTracker), JobBag()) } return createStateViewModel { profileReducer() }
} }
fun profileReducer() = profileReducer(chatEngine, errorTracker, ProfileUseCase(chatEngine, errorTracker), JobBag())
} }

@ -1 +1 @@
Subproject commit a0425cb9196ba728309b1f2ab616df6ad1168b90 Subproject commit 9abb6b4418f451d81f09c4ba2b26f2b1ffd19f55