From ac2c975d3f9fe7d40c0a1bad9ff192de46ccac33 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 29 Dec 2022 20:05:10 +0000 Subject: [PATCH 1/5] Avoiding state getters on home model --- .../src/main/kotlin/app/dapk/st/home/HomeScreen.kt | 11 +++++++---- .../src/main/kotlin/app/dapk/st/home/HomeViewModel.kt | 4 ---- .../src/main/kotlin/app/dapk/st/home/MainActivity.kt | 2 +- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeScreen.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeScreen.kt index 5f8ce7df..2cca6ae8 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/HomeScreen.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/HomeScreen.kt @@ -10,15 +10,18 @@ import app.dapk.st.core.LifecycleEffect 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.login.LoginScreen +import app.dapk.st.login.state.LoginState import app.dapk.st.profile.ProfileScreen +import app.dapk.st.profile.state.ProfileState @OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun HomeScreen(homeViewModel: HomeViewModel) { +internal fun HomeScreen(homeViewModel: HomeViewModel, directoryState: DirectoryState, loginState: LoginState, profileState: ProfileState) { LifecycleEffect( onStart = { homeViewModel.start() }, onStop = { homeViewModel.stop() } @@ -34,9 +37,9 @@ internal fun HomeScreen(homeViewModel: HomeViewModel) { content = { innerPadding -> Box(modifier = Modifier.padding(innerPadding)) { when (state.page) { - Directory -> DirectoryScreen(homeViewModel.directory()) + Directory -> DirectoryScreen(directoryState) Profile -> { - ProfileScreen(homeViewModel.profile()) { + ProfileScreen(profileState) { homeViewModel.changePage(Directory) } } @@ -47,7 +50,7 @@ internal fun HomeScreen(homeViewModel: HomeViewModel) { } SignedOut -> { - LoginScreen(homeViewModel.login()) { + LoginScreen(loginState) { homeViewModel.loggedIn() } } diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt index 30174fef..b9cc9828 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt @@ -30,10 +30,6 @@ internal class HomeViewModel( private var listenForInvitesJob: Job? = null - fun directory() = directoryState - fun login() = loginState - fun profile() = profileState - fun start() { viewModelScope.launch { state = if (chatEngine.isSignedIn()) { diff --git a/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt b/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt index fb41b052..a3315b36 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt @@ -43,7 +43,7 @@ class MainActivity : DapkActivity() { BetaUpgradeDialog() } else { Surface(Modifier.fillMaxSize()) { - HomeScreen(homeViewModel) + HomeScreen(homeViewModel, directoryState, loginState, profileState) } } } From c6ad944994eee24792a6695a28985b32b465f18c Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 29 Dec 2022 20:42:15 +0000 Subject: [PATCH 2/5] Downscoping the shared flow to avoid leaking downstream --- .../src/main/kotlin/app/dapk/st/core/ComposeExtensions.kt | 5 +++++ screen-state | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/ComposeExtensions.kt b/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/ComposeExtensions.kt index 7458cd9d..117190ac 100644 --- a/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/ComposeExtensions.kt +++ b/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/ComposeExtensions.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -27,6 +28,10 @@ class StartScope(private val scope: CoroutineScope) { fun SharedFlow.launch(onEach: suspend (T) -> Unit) { this.onEach(onEach).launchIn(scope) } + + fun Flow.launch(onEach: suspend (T) -> Unit) { + this.onEach(onEach).launchIn(scope) + } } interface EffectScope { diff --git a/screen-state b/screen-state index ea31ab26..a0425cb9 160000 --- a/screen-state +++ b/screen-state @@ -1 +1 @@ -Subproject commit ea31ab26de443ed5e6bb67ce594e3ce8d5f04ff3 +Subproject commit a0425cb9196ba728309b1f2ab616df6ad1168b90 From 7a8c8ed88d7c1799587c3e9612e02365a5c6929b Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 30 Dec 2022 13:24:05 +0000 Subject: [PATCH 3/5] Convert home view model to reducer --- .../app/dapk/st/SmallTalkApplication.kt | 2 +- .../kotlin/app/dapk/st/graph/AppModule.kt | 3 + .../app/dapk/st/directory/DirectoryModule.kt | 9 +- features/home/build.gradle | 1 - .../kotlin/app/dapk/st/home/HomeModule.kt | 50 +++++-- .../kotlin/app/dapk/st/home/HomeScreen.kt | 30 +++-- .../kotlin/app/dapk/st/home/HomeViewModel.kt | 127 ------------------ .../kotlin/app/dapk/st/home/MainActivity.kt | 51 ++++--- .../dapk/st/home/state/CreateHomeReducer.kt | 116 ++++++++++++++++ .../app/dapk/st/home/state/HomeAction.kt | 18 +++ .../app/dapk/st/home/{ => state}/HomeState.kt | 5 +- .../kotlin/app/dapk/st/login/LoginModule.kt | 13 +- .../app/dapk/st/profile/ProfileModule.kt | 4 +- screen-state | 2 +- 14 files changed, 238 insertions(+), 193 deletions(-) delete mode 100644 features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt create mode 100644 features/home/src/main/kotlin/app/dapk/st/home/state/CreateHomeReducer.kt create mode 100644 features/home/src/main/kotlin/app/dapk/st/home/state/HomeAction.kt rename features/home/src/main/kotlin/app/dapk/st/home/{ => state}/HomeState.kt (84%) diff --git a/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt b/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt index 8a078b7a..3310b37e 100644 --- a/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt +++ b/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt @@ -67,7 +67,7 @@ class SmallTalkApplication : Application(), ModuleProvider { } } - @Suppress("UNCHECKED_CAST", "IMPLICIT_CAST_TO_ANY") + @Suppress("UNCHECKED_CAST") override fun provide(klass: KClass): T { return when (klass) { DirectoryModule::class -> featureModules.directoryModule diff --git a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt index afe30975..9a059c1e 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -190,6 +190,9 @@ internal class FeatureModules internal constructor( storeModule.value.applicationStore(), buildMeta, ), + profileModule, + loginModule, + directoryModule ) } val settingsModule by unsafeLazy { diff --git a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryModule.kt b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryModule.kt index 8ef9027f..23318cb4 100644 --- a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryModule.kt +++ b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryModule.kt @@ -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) } diff --git a/features/home/build.gradle b/features/home/build.gradle index fc94fe67..df993401 100644 --- a/features/home/build.gradle +++ b/features/home/build.gradle @@ -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") diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt index 9cdd68fe..9a716cdd 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt @@ -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) + ) + ) + } } -} \ No newline at end of file + private fun homeReducerFactory(eventEmitter: suspend (Any) -> Unit) = + createHomeReducer(chatEngine, storeModule.cacheCleaner(), betaVersionUpgradeUseCase, JobBag(), eventEmitter) +} + +typealias DynamicState = State + +inline fun DynamicState.childState() = object : State { + override fun dispatch(action: Action) = this@childState.dispatch(action) + override val events: Flow = this@childState.events.filterIsInstance() + override val current: S = this@childState.current.getState() +} diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeScreen.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeScreen.kt index 2cca6ae8..22802a1c 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/HomeScreen.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/HomeScreen.kt @@ -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)) }, ) } } diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt deleted file mode 100644 index b9cc9828..00000000 --- a/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt +++ /dev/null @@ -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( - 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 - } -} diff --git a/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt b/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt index a3315b36..268840c8 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt @@ -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().directoryState() } - private val loginState by state { module().loginState() } - private val profileState by state { module().profileState() } - private val homeViewModel by viewModel { module().homeViewModel(directoryState, loginState, profileState) } + private val homeModule by unsafeLazy { module() } + 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,20 +55,20 @@ class MainActivity : DapkActivity() { null } } +} - @Composable - private fun BetaUpgradeDialog() { - AlertDialog( - title = { Text(text = "BETA") }, - text = { Text(text = "During the BETA, version upgrades require a cache clear") }, - onDismissRequest = { +@Composable +private fun BetaUpgradeDialog(homeState: HomeState) { + AlertDialog( + title = { Text(text = "BETA") }, + text = { Text(text = "During the BETA, version upgrades require a cache clear") }, + onDismissRequest = { - }, - confirmButton = { - TextButton(onClick = { homeViewModel.clearCache() }) { - Text(text = "Clear cache".uppercase()) - } - }, - ) - } + }, + confirmButton = { + TextButton(onClick = { homeState.dispatch(HomeAction.ClearCache) }) { + Text(text = "Clear cache".uppercase()) + } + }, + ) } diff --git a/features/home/src/main/kotlin/app/dapk/st/home/state/CreateHomeReducer.kt b/features/home/src/main/kotlin/app/dapk/st/home/state/CreateHomeReducer.kt new file mode 100644 index 00000000..f330c67e --- /dev/null +++ b/features/home/src/main/kotlin/app/dapk/st/home/state/CreateHomeReducer.kt @@ -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 { + 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.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) + ) +} \ No newline at end of file diff --git a/features/home/src/main/kotlin/app/dapk/st/home/state/HomeAction.kt b/features/home/src/main/kotlin/app/dapk/st/home/state/HomeAction.kt new file mode 100644 index 00000000..25bc9cda --- /dev/null +++ b/features/home/src/main/kotlin/app/dapk/st/home/state/HomeAction.kt @@ -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 +} diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeState.kt b/features/home/src/main/kotlin/app/dapk/st/home/state/HomeState.kt similarity index 84% rename from features/home/src/main/kotlin/app/dapk/st/home/HomeState.kt rename to features/home/src/main/kotlin/app/dapk/st/home/state/HomeState.kt index 22ec6aa6..151ae60c 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/HomeState.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/state/HomeState.kt @@ -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 sealed interface HomeScreenState { diff --git a/features/login/src/main/kotlin/app/dapk/st/login/LoginModule.kt b/features/login/src/main/kotlin/app/dapk/st/login/LoginModule.kt index 34251e7b..b3bf5b67 100644 --- a/features/login/src/main/kotlin/app/dapk/st/login/LoginModule.kt +++ b/features/login/src/main/kotlin/app/dapk/st/login/LoginModule.kt @@ -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 { + val loginUseCase = LoginUseCase(chatEngine, pushModule.pushTokenRegistrars(), errorTracker) + return loginReducer(loginUseCase, eventEmitter) + } } \ No newline at end of file diff --git a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileModule.kt b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileModule.kt index 165f993b..f2be4a29 100644 --- a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileModule.kt +++ b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileModule.kt @@ -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()) + } \ No newline at end of file diff --git a/screen-state b/screen-state index a0425cb9..9abb6b44 160000 --- a/screen-state +++ b/screen-state @@ -1 +1 @@ -Subproject commit a0425cb9196ba728309b1f2ab616df6ad1168b90 +Subproject commit 9abb6b4418f451d81f09c4ba2b26f2b1ffd19f55 From 58470e2dcd56d335571ac22494d128e8cb52736b Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 30 Dec 2022 15:53:16 +0000 Subject: [PATCH 4/5] Add home reducer tests - reworks the multi state --- chat-engine | 2 +- features/home/build.gradle | 9 ++- .../kotlin/app/dapk/st/home/HomeModule.kt | 4 +- .../app/dapk/st/home/state/HomeAction.kt | 3 + .../{CreateHomeReducer.kt => HomeReducer.kt} | 58 +++++++++++-------- .../dapk/st/settings/state/SettingsReducer.kt | 1 - screen-state | 2 +- 7 files changed, 48 insertions(+), 31 deletions(-) rename features/home/src/main/kotlin/app/dapk/st/home/state/{CreateHomeReducer.kt => HomeReducer.kt} (76%) diff --git a/chat-engine b/chat-engine index cdf3e1bf..9017fe39 160000 --- a/chat-engine +++ b/chat-engine @@ -1 +1 @@ -Subproject commit cdf3e1bffba4b69dd8f752c6cc7588b0e89a17af +Subproject commit 9017fe3963754199db7c2525ba38a3265ef5701d diff --git a/features/home/build.gradle b/features/home/build.gradle index df993401..4be72259 100644 --- a/features/home/build.gradle +++ b/features/home/build.gradle @@ -8,14 +8,21 @@ android { dependencies { implementation "chat-engine:chat-engine" + implementation 'screen-state:screen-android' implementation project(":features:directory") implementation project(":features:login") implementation project(":features:settings") implementation project(":features:profile") implementation project(":domains:android:compose-core") implementation project(':domains:store') - implementation 'screen-state:screen-android' implementation project(":core") implementation project(":design-library") implementation libs.compose.coil + + kotlinTest(it) + + testImplementation 'screen-state:state-test' + testImplementation 'chat-engine:chat-engine-test' + androidImportFixturesWorkaround(project, project(":core")) + androidImportFixturesWorkaround(project, project(":domains:android:stub")) } \ No newline at end of file diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt index 9a716cdd..8077f32c 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt @@ -5,7 +5,7 @@ import app.dapk.st.core.ProvidableModule import app.dapk.st.directory.DirectoryModule import app.dapk.st.domain.StoreModule import app.dapk.st.engine.ChatEngine -import app.dapk.st.home.state.createHomeReducer +import app.dapk.st.home.state.homeReducer import app.dapk.st.login.LoginModule import app.dapk.st.profile.ProfileModule import app.dapk.st.state.State @@ -39,7 +39,7 @@ class HomeModule( } private fun homeReducerFactory(eventEmitter: suspend (Any) -> Unit) = - createHomeReducer(chatEngine, storeModule.cacheCleaner(), betaVersionUpgradeUseCase, JobBag(), eventEmitter) + homeReducer(chatEngine, storeModule.cacheCleaner(), betaVersionUpgradeUseCase, JobBag(), eventEmitter) } typealias DynamicState = State diff --git a/features/home/src/main/kotlin/app/dapk/st/home/state/HomeAction.kt b/features/home/src/main/kotlin/app/dapk/st/home/state/HomeAction.kt index 25bc9cda..fc58d2e2 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/state/HomeAction.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/state/HomeAction.kt @@ -1,5 +1,6 @@ package app.dapk.st.home.state +import app.dapk.st.engine.Me import app.dapk.st.home.state.HomeScreenState.Page import app.dapk.state.Action @@ -12,7 +13,9 @@ sealed interface HomeAction : Action { object LoggedIn : HomeAction data class ChangePage(val page: Page) : HomeAction + data class ChangePageSideEffect(val page: Page) : HomeAction data class UpdateInvitesCount(val invitesCount: Int) : HomeAction + data class UpdateToSignedIn(val me: Me) : HomeAction data class UpdateState(val state: HomeScreenState) : HomeAction object InitialHome : HomeAction } diff --git a/features/home/src/main/kotlin/app/dapk/st/home/state/CreateHomeReducer.kt b/features/home/src/main/kotlin/app/dapk/st/home/state/HomeReducer.kt similarity index 76% rename from features/home/src/main/kotlin/app/dapk/st/home/state/CreateHomeReducer.kt rename to features/home/src/main/kotlin/app/dapk/st/home/state/HomeReducer.kt index f330c67e..f788da1d 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/state/CreateHomeReducer.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/state/HomeReducer.kt @@ -11,7 +11,7 @@ import app.dapk.state.* import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -fun createHomeReducer( +fun homeReducer( chatEngine: ChatEngine, cacheCleaner: StoreCleaner, betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase, @@ -25,6 +25,15 @@ fun createHomeReducer( action.state }, + change(HomeAction.UpdateToSignedIn::class) { action, state -> + val me = action.me + when (state) { + HomeScreenState.Loading -> HomeScreenState.SignedIn(HomeScreenState.Page.Directory, me, invites = 0) + is HomeScreenState.SignedIn -> state.copy(me = me, invites = state.invites) + HomeScreenState.SignedOut -> HomeScreenState.SignedIn(HomeScreenState.Page.Directory, me, invites = 0) + } + }, + change(HomeAction.UpdateInvitesCount::class) { action, state -> when (state) { HomeScreenState.Loading -> state @@ -37,7 +46,6 @@ fun createHomeReducer( if (chatEngine.isSignedIn()) { eventEmitter.invoke(HomeEvent.OnShowContent) dispatch(HomeAction.InitialHome) - listenForInviteChanges(chatEngine, jobBag) } else { dispatch(HomeAction.UpdateState(HomeScreenState.SignedOut)) } @@ -45,18 +53,26 @@ fun createHomeReducer( 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)) + dispatch(HomeAction.UpdateToSignedIn(me)) + listenForInviteChanges(chatEngine, jobBag) }, async(HomeAction.LoggedIn::class) { dispatch(HomeAction.InitialHome) eventEmitter.invoke(HomeEvent.OnShowContent) - listenForInviteChanges(chatEngine, jobBag) + }, + + async(HomeAction.ChangePageSideEffect::class) { action -> + when (action.page) { + HomeScreenState.Page.Directory -> { + // do nothing + } + + HomeScreenState.Page.Profile -> { + dispatch(ComponentLifecycle.OnGone) + dispatch(ProfileAction.Reset) + } + } }, multi(HomeAction.ChangePage::class) { action -> @@ -64,38 +80,30 @@ fun createHomeReducer( 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) - } - } - } - } + else -> state.copy(page = action.page) } HomeScreenState.Loading -> state HomeScreenState.SignedOut -> state } } + async { + val state = getState() + if (state is HomeScreenState.SignedIn && state.page != action.page) { + dispatch(HomeAction.ChangePageSideEffect(action.page)) + } + } }, async(HomeAction.ScrollToTop::class) { dispatch(DirectorySideEffect.ScrollToTop) }, - sideEffect(HomeAction.ClearCache::class) { _, _ -> cacheCleaner.cleanCache(removeCredentials = false) betaVersionUpgradeUseCase.notifyUpgraded() eventEmitter.invoke(HomeEvent.Relaunch) - } + }, ) } diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/state/SettingsReducer.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/state/SettingsReducer.kt index 075661c6..5cfdaa86 100644 --- a/features/settings/src/main/kotlin/app/dapk/st/settings/state/SettingsReducer.kt +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/state/SettingsReducer.kt @@ -144,7 +144,6 @@ internal fun settingsReducer( } Ignored -> { - nothing() } ToggleDynamicTheme -> async { diff --git a/screen-state b/screen-state index 9abb6b44..d596949a 160000 --- a/screen-state +++ b/screen-state @@ -1 +1 @@ -Subproject commit 9abb6b4418f451d81f09c4ba2b26f2b1ffd19f55 +Subproject commit d596949ac2b923b02da55ddd78e2e26dc46af82a From 167c3f81c4697c2587daaec20910fa32618cf2a9 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 30 Dec 2022 16:38:49 +0000 Subject: [PATCH 5/5] Add beta version usecase tests --- .../dapk/st/domain/ApplicationPreferences.kt | 3 +- .../dapk/st/home/BetaVersionUpgradeUseCase.kt | 3 +- .../st/home/BetaVersionUpgradeUseCaseTest.kt | 68 ++++++ .../app/dapk/st/home/state/HomeReducerTest.kt | 224 ++++++++++++++++++ 4 files changed, 295 insertions(+), 3 deletions(-) create mode 100644 features/home/src/test/kotlin/app/dapk/st/home/BetaVersionUpgradeUseCaseTest.kt create mode 100644 features/home/src/test/kotlin/app/dapk/st/home/state/HomeReducerTest.kt diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/ApplicationPreferences.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/ApplicationPreferences.kt index a0a4d592..dfddea96 100644 --- a/domains/store/src/main/kotlin/app/dapk/st/domain/ApplicationPreferences.kt +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/ApplicationPreferences.kt @@ -16,6 +16,5 @@ class ApplicationPreferences( } -@JvmInline -value class ApplicationVersion(val value: Int) +data class ApplicationVersion(val value: Int) diff --git a/features/home/src/main/kotlin/app/dapk/st/home/BetaVersionUpgradeUseCase.kt b/features/home/src/main/kotlin/app/dapk/st/home/BetaVersionUpgradeUseCase.kt index 565e8106..2ba36a41 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/BetaVersionUpgradeUseCase.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/BetaVersionUpgradeUseCase.kt @@ -20,7 +20,8 @@ class BetaVersionUpgradeUseCase( } private suspend fun hasChangedVersion(): Boolean { - val previousVersion = applicationPreferences.readVersion()?.value + val readVersion = applicationPreferences.readVersion() + val previousVersion = readVersion?.value val currentVersion = buildMeta.versionCode return when (previousVersion) { null -> false diff --git a/features/home/src/test/kotlin/app/dapk/st/home/BetaVersionUpgradeUseCaseTest.kt b/features/home/src/test/kotlin/app/dapk/st/home/BetaVersionUpgradeUseCaseTest.kt new file mode 100644 index 00000000..5a7d5921 --- /dev/null +++ b/features/home/src/test/kotlin/app/dapk/st/home/BetaVersionUpgradeUseCaseTest.kt @@ -0,0 +1,68 @@ +package app.dapk.st.home + +import app.dapk.st.core.BuildMeta +import app.dapk.st.domain.ApplicationPreferences +import app.dapk.st.domain.ApplicationVersion +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import test.delegateReturn +import test.expect + +class BetaVersionUpgradeUseCaseTest { + + private val buildMeta = BuildMeta(versionName = "a-version-name", versionCode = 100, isDebug = false) + private val fakeApplicationPreferences = FakeApplicationPreferences() + + private val useCase = BetaVersionUpgradeUseCase( + fakeApplicationPreferences.instance, + buildMeta + ) + + @Test + fun `given same stored version, when hasVersionChanged then is false`() = runTest { + fakeApplicationPreferences.givenVersion().returns(ApplicationVersion(buildMeta.versionCode)) + + val result = useCase.hasVersionChanged() + + result shouldBeEqualTo false + } + + // Should be impossible + @Test + fun `given higher stored version, when hasVersionChanged then is false`() = runTest { + fakeApplicationPreferences.givenVersion().returns(ApplicationVersion(buildMeta.versionCode + 1)) + + val result = useCase.hasVersionChanged() + + result shouldBeEqualTo false + } + + @Test + fun `given lower stored version, when hasVersionChanged then is true`() = runTest { + fakeApplicationPreferences.givenVersion().returns(ApplicationVersion(buildMeta.versionCode - 1)) + + val result = useCase.hasVersionChanged() + + result shouldBeEqualTo true + } + + @Test + fun `given version has changed, when waiting, then blocks until notified of upgrade`() = runTest { + fakeApplicationPreferences.givenVersion().returns(ApplicationVersion(buildMeta.versionCode - 1)) + fakeApplicationPreferences.instance.expect { it.setVersion(ApplicationVersion(buildMeta.versionCode)) } + + val waitUntilReady = async { useCase.waitUnitReady() } + async { useCase.notifyUpgraded() } + waitUntilReady.await() + } +} + +private class FakeApplicationPreferences { + val instance = mockk() + + fun givenVersion() = coEvery { instance.readVersion() }.delegateReturn() +} \ No newline at end of file diff --git a/features/home/src/test/kotlin/app/dapk/st/home/state/HomeReducerTest.kt b/features/home/src/test/kotlin/app/dapk/st/home/state/HomeReducerTest.kt new file mode 100644 index 00000000..4c25f0cb --- /dev/null +++ b/features/home/src/test/kotlin/app/dapk/st/home/state/HomeReducerTest.kt @@ -0,0 +1,224 @@ +package app.dapk.st.home.state + +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.Me +import app.dapk.st.home.BetaVersionUpgradeUseCase +import app.dapk.st.matrix.common.HomeServerUrl +import app.dapk.st.profile.state.ProfileAction +import fake.FakeChatEngine +import fake.FakeJobBag +import fixture.aRoomId +import fixture.aRoomInvite +import fixture.aUserId +import io.mockk.mockk +import org.junit.Test +import test.* + +private val A_ME = Me(aUserId(), displayName = null, avatarUrl = null, homeServerUrl = HomeServerUrl("ignored")) +private val A_SIGNED_IN_STATE = HomeScreenState.SignedIn( + HomeScreenState.Page.Directory, + me = A_ME, + invites = 0, +) + +class HomeReducerTest { + + private val fakeStoreCleaner = FakeStoreCleaner() + private val fakeChatEngine = FakeChatEngine() + private val fakeBetaVersionUpgradeUseCase = FakeBetaVersionUpgradeUseCase() + private val fakeJobBag = FakeJobBag() + + private val runReducerTest = testReducer { fakeEventSource -> + homeReducer( + fakeChatEngine, + fakeStoreCleaner, + fakeBetaVersionUpgradeUseCase.instance, + fakeJobBag.instance, + fakeEventSource, + ) + } + + @Test + fun `initial state is loading`() = runReducerTest { + assertInitialState(HomeScreenState.Loading) + } + + @Test + fun `when UpdateState, then replaces state`() = runReducerTest { + reduce(HomeAction.UpdateState(HomeScreenState.SignedOut)) + + assertOnlyStateChange(HomeScreenState.SignedOut) + } + + @Test + fun `given SignedIn, when UpdateInviteCount, then updates invite count`() = runReducerTest { + setState(A_SIGNED_IN_STATE) + + reduce(HomeAction.UpdateInvitesCount(invitesCount = 90)) + + assertOnlyStateChange(A_SIGNED_IN_STATE.copy(invites = 90)) + } + + @Test + fun `when ScrollToTop, then forwards to directory scroll event`() = runReducerTest { + reduce(HomeAction.ScrollToTop) + + assertOnlyDispatches(DirectorySideEffect.ScrollToTop) + } + + @Test + fun `when ClearCache, then clears store cache, upgrades and relaunches`() = runReducerTest { + fakeStoreCleaner.expect { it.cleanCache(removeCredentials = false) } + fakeBetaVersionUpgradeUseCase.instance.expect { it.notifyUpgraded() } + + reduce(HomeAction.ClearCache) + + assertOnlyEvents(HomeEvent.Relaunch) + } + + @Test + fun `given SignedIn and invites update, when Visible, then show content and update on invite changes`() = runReducerTest { + fakeChatEngine.givenIsSignedIn().returns(true) + + reduce(HomeAction.LifecycleVisible) + + assertEvents(HomeEvent.OnShowContent) + assertDispatches(HomeAction.InitialHome) + assertNoStateChange() + } + + @Test + fun `given SignedOut and invites update, when Visible, then show content and update on invite changes`() = runReducerTest { + fakeChatEngine.givenIsSignedIn().returns(false) + + reduce(HomeAction.LifecycleVisible) + + assertOnlyDispatches(HomeAction.UpdateState(HomeScreenState.SignedOut)) + } + + @Test + fun `given SignedIn, when InitialHome, then updates me state and listens to invite changes`() = runReducerTest { + setState(A_SIGNED_IN_STATE) + fakeChatEngine.givenMe(forceRefresh = false).returns(A_ME) + givenInvites(count = 5) + + reduce(HomeAction.InitialHome) + + assertOnlyDispatches( + HomeAction.UpdateToSignedIn(A_ME), + HomeAction.UpdateInvitesCount(5) + ) + } + + @Test + fun `given SignedIn, when UpdateToSignedIn, then updates me state`() = runReducerTest { + setState(A_SIGNED_IN_STATE) + val expectedMe = A_ME.copy(aUserId("another-user")) + + reduce(HomeAction.UpdateToSignedIn(expectedMe)) + + assertOnlyStateChange(A_SIGNED_IN_STATE.copy(me = expectedMe)) + } + + @Test + fun `given Loading, when UpdateToSignedIn, then set SignedIn and updates me state`() = runReducerTest { + setState(HomeScreenState.Loading) + val expectedMe = A_ME.copy(aUserId("another-user")) + + reduce(HomeAction.UpdateToSignedIn(expectedMe)) + + assertOnlyStateChange(A_SIGNED_IN_STATE.copy(me = expectedMe)) + } + + @Test + fun `given SignedOut, when UpdateToSignedIn, then set SignedIn and updates me state`() = runReducerTest { + setState(HomeScreenState.SignedOut) + val expectedMe = A_ME.copy(aUserId("another-user")) + + reduce(HomeAction.UpdateToSignedIn(expectedMe)) + + assertOnlyStateChange(A_SIGNED_IN_STATE.copy(me = expectedMe)) + } + + @Test + fun `when LoggedIn, then emit show content and fetch initial home`() = runReducerTest { + setState(HomeScreenState.SignedOut) + givenInvites(count = 0) + + reduce(HomeAction.LoggedIn) + + assertDispatches(HomeAction.InitialHome) + assertEvents(HomeEvent.OnShowContent) + assertNoStateChange() + } + + @Test + fun `given SignedOut, when ChangePage, then does nothing`() = runReducerTest { + setState(HomeScreenState.SignedOut) + + reduce(HomeAction.ChangePage(HomeScreenState.Page.Directory)) + + assertNoChanges() + } + + @Test + fun `given Loading, when ChangePage, then does nothing`() = runReducerTest { + setState(HomeScreenState.Loading) + + reduce(HomeAction.ChangePage(HomeScreenState.Page.Directory)) + + assertNoChanges() + } + + @Test + fun `given SignedIn, when ChangePage to same page, then does nothing`() = runReducerTest { + val page = HomeScreenState.Page.Directory + setState(A_SIGNED_IN_STATE.copy(page = page)) + + reduce(HomeAction.ChangePage(page)) + + assertNoChanges() + } + + @Test + fun `given SignedIn, when ChangePage to different page, then updates page and emits side effect`() = runReducerTest { + val expectedPage = HomeScreenState.Page.Profile + setState(A_SIGNED_IN_STATE.copy(page = HomeScreenState.Page.Directory)) + + reduce(HomeAction.ChangePage(expectedPage)) + + assertStateChange(A_SIGNED_IN_STATE.copy(page = expectedPage)) + assertDispatches(HomeAction.ChangePageSideEffect(expectedPage)) + } + + @Test + fun `when ChangePageSide is Directory, then does nothing`() = runReducerTest { + reduce(HomeAction.ChangePageSideEffect(HomeScreenState.Page.Directory)) + + assertNoChanges() + } + + @Test + fun `when ChangePageSide is Profile, then mark directory gone and resets profile`() = runReducerTest { + reduce(HomeAction.ChangePageSideEffect(HomeScreenState.Page.Profile)) + + assertOnlyDispatches( + ComponentLifecycle.OnGone, + ProfileAction.Reset + ) + } + + private fun givenInvites(count: Int) { + fakeJobBag.instance.expect { it.replace("invites-count", any()) } + val invites = List(count) { aRoomInvite(roomId = aRoomId(it.toString())) } + fakeChatEngine.givenInvites().emits(invites) + } +} + +class FakeStoreCleaner : StoreCleaner by mockk() + +class FakeBetaVersionUpgradeUseCase { + val instance = mockk() +} \ No newline at end of file