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 {
return when (klass) {
DirectoryModule::class -> featureModules.directoryModule

View File

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

View File

@ -1,12 +1,13 @@
package app.dapk.st.directory
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.ProvidableModule
import app.dapk.st.directory.state.DirectoryEvent
import app.dapk.st.directory.state.DirectoryState
import app.dapk.st.directory.state.directoryReducer
import app.dapk.st.engine.ChatEngine
import app.dapk.st.state.createStateViewModel
class DirectoryModule(
private val context: Context,
@ -14,6 +15,8 @@ class DirectoryModule(
) : ProvidableModule {
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:profile")
implementation project(":domains:android:compose-core")
implementation project(":domains:android:viewmodel")
implementation project(':domains:store')
implementation 'screen-state:screen-android'
implementation project(":core")

View File

@ -1,27 +1,51 @@
package app.dapk.st.home
import app.dapk.st.core.JobBag
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.engine.ChatEngine
import app.dapk.st.login.state.LoginState
import app.dapk.st.profile.state.ProfileState
import app.dapk.st.home.state.createHomeReducer
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(
private val chatEngine: ChatEngine,
private val storeModule: StoreModule,
val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase,
private val profileModule: ProfileModule,
private val loginModule: LoginModule,
private val directoryModule: DirectoryModule,
) : ProvidableModule {
internal fun homeViewModel(directory: DirectoryState, login: LoginState, profile: ProfileState): HomeViewModel {
return HomeViewModel(
chatEngine,
directory,
login,
profile,
storeModule.cacheCleaner(),
betaVersionUpgradeUseCase,
internal fun compositeHomeState(): DynamicState {
return createStateViewModel {
combineReducers(
listOf(
homeReducerFactory(it),
loginModule.loginReducer(it),
profileModule.profileReducer(),
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.directory.DirectoryScreen
import app.dapk.st.directory.state.DirectoryState
import app.dapk.st.home.HomeScreenState.*
import app.dapk.st.home.HomeScreenState.Page.Directory
import app.dapk.st.home.HomeScreenState.Page.Profile
import app.dapk.st.home.state.HomeAction
import app.dapk.st.home.state.HomeScreenState.*
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.state.LoginState
import app.dapk.st.profile.ProfileScreen
@ -21,18 +23,18 @@ import app.dapk.st.profile.state.ProfileState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun HomeScreen(homeViewModel: HomeViewModel, directoryState: DirectoryState, loginState: LoginState, profileState: ProfileState) {
internal fun HomeScreen(homeState: HomeState, directoryState: DirectoryState, loginState: LoginState, profileState: ProfileState) {
LifecycleEffect(
onStart = { homeViewModel.start() },
onStop = { homeViewModel.stop() }
onStart = { homeState.dispatch(HomeAction.LifecycleVisible) },
onStop = { homeState.dispatch(HomeAction.LifecycleGone) }
)
when (val state = homeViewModel.state) {
when (val state = homeState.current) {
Loading -> CenteredLoading()
is SignedIn -> {
Scaffold(
bottomBar = {
BottomBar(state, homeViewModel)
BottomBar(state, homeState)
},
content = { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {
@ -40,7 +42,7 @@ internal fun HomeScreen(homeViewModel: HomeViewModel, directoryState: DirectoryS
Directory -> DirectoryScreen(directoryState)
Profile -> {
ProfileScreen(profileState) {
homeViewModel.changePage(Directory)
homeState.dispatch(HomeAction.ChangePage(Directory))
}
}
}
@ -51,7 +53,7 @@ internal fun HomeScreen(homeViewModel: HomeViewModel, directoryState: DirectoryS
SignedOut -> {
LoginScreen(loginState) {
homeViewModel.loggedIn()
homeState.dispatch(HomeAction.LoggedIn)
}
}
}
@ -59,7 +61,7 @@ internal fun HomeScreen(homeViewModel: HomeViewModel, directoryState: DirectoryS
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BottomBar(state: SignedIn, homeViewModel: HomeViewModel) {
private fun BottomBar(state: SignedIn, homeState: HomeState) {
Column {
Divider(modifier = Modifier.fillMaxWidth(), color = Color.Black.copy(alpha = 0.2f), thickness = 0.5.dp)
NavigationBar(containerColor = Color.Transparent, modifier = Modifier.height(IntrinsicSize.Min)) {
@ -70,8 +72,8 @@ private fun BottomBar(state: SignedIn, homeViewModel: HomeViewModel) {
selected = state.page == page,
onClick = {
when {
state.page == page -> homeViewModel.scrollToTopOfMessages()
else -> homeViewModel.changePage(page)
state.page == page -> homeState.dispatch(HomeAction.ScrollToTop)
else -> homeState.dispatch(HomeAction.ChangePage(page))
}
},
)
@ -89,7 +91,7 @@ private fun BottomBar(state: SignedIn, homeViewModel: HomeViewModel) {
}
},
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.lifecycle.lifecycleScope
import app.dapk.st.core.DapkActivity
import app.dapk.st.core.extensions.unsafeLazy
import app.dapk.st.core.module
import app.dapk.st.core.viewModel
import app.dapk.st.directory.DirectoryModule
import app.dapk.st.login.LoginModule
import app.dapk.st.profile.ProfileModule
import app.dapk.st.home.state.HomeAction
import app.dapk.st.home.state.HomeEvent
import app.dapk.st.home.state.HomeState
import app.dapk.st.state.state
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
class MainActivity : DapkActivity() {
private val directoryState by state { module<DirectoryModule>().directoryState() }
private val loginState by state { module<LoginModule>().loginState() }
private val profileState by state { module<ProfileModule>().profileState() }
private val homeViewModel by viewModel { module<HomeModule>().homeViewModel(directoryState, loginState, profileState) }
private val homeModule by unsafeLazy { module<HomeModule>() }
private val compositeState by state { homeModule.compositeHomeState() }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pushPermissionLauncher = registerPushPermission()
homeViewModel.events.onEach {
compositeState.events.onEach {
when (it) {
HomeEvent.Relaunch -> recreate()
HomeEvent.OnShowContent -> pushPermissionLauncher?.invoke()
@ -39,11 +37,12 @@ class MainActivity : DapkActivity() {
}.launchIn(lifecycleScope)
setContent {
if (homeViewModel.hasVersionChanged()) {
BetaUpgradeDialog()
val homeState: HomeState = compositeState.childState()
if (homeModule.betaVersionUpgradeUseCase.hasVersionChanged()) {
BetaUpgradeDialog(homeState)
} else {
Surface(Modifier.fillMaxSize()) {
HomeScreen(homeViewModel, directoryState, loginState, profileState)
HomeScreen(homeState, compositeState.childState(), compositeState.childState(), compositeState.childState())
}
}
}
@ -56,9 +55,10 @@ class MainActivity : DapkActivity() {
null
}
}
}
@Composable
private fun BetaUpgradeDialog() {
@Composable
private fun BetaUpgradeDialog(homeState: HomeState) {
AlertDialog(
title = { Text(text = "BETA") },
text = { Text(text = "During the BETA, version upgrades require a cache clear") },
@ -66,10 +66,9 @@ class MainActivity : DapkActivity() {
},
confirmButton = {
TextButton(onClick = { homeViewModel.clearCache() }) {
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.filled.Menu
import androidx.compose.material.icons.filled.Settings
import androidx.compose.ui.graphics.vector.ImageVector
import app.dapk.st.engine.Me
import app.dapk.st.state.State
typealias HomeState = State<HomeScreenState, HomeEvent>
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.extensions.ErrorTracker
import app.dapk.st.engine.ChatEngine
import app.dapk.st.login.state.LoginState
import app.dapk.st.login.state.LoginUseCase
import app.dapk.st.login.state.loginReducer
import app.dapk.st.login.state.*
import app.dapk.st.push.PushModule
import app.dapk.st.state.createStateViewModel
import app.dapk.state.ReducerFactory
class LoginModule(
private val chatEngine: ChatEngine,
@ -17,8 +16,12 @@ class LoginModule(
fun loginState(): LoginState {
return createStateViewModel {
val loginUseCase = LoginUseCase(chatEngine, pushModule.pushTokenRegistrars(), errorTracker)
loginReducer(loginUseCase, it)
loginReducer(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 {
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