Merge pull request #298 from ouchadam/tech/home-reducer

Tech/home reducer
This commit is contained in:
Adam Brown 2022-12-30 16:50:37 +00:00 committed by GitHub
commit bfc4e83ee7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 566 additions and 208 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 {

@ -1 +1 @@
Subproject commit cdf3e1bffba4b69dd8f752c6cc7588b0e89a17af
Subproject commit 9017fe3963754199db7c2525ba38a3265ef5701d

View File

@ -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 <T> SharedFlow<T>.launch(onEach: suspend (T) -> Unit) {
this.onEach(onEach).launchIn(scope)
}
fun <T> Flow<T>.launch(onEach: suspend (T) -> Unit) {
this.onEach(onEach).launchIn(scope)
}
}
interface EffectScope {

View File

@ -16,6 +16,5 @@ class ApplicationPreferences(
}
@JvmInline
value class ApplicationVersion(val value: Int)
data class ApplicationVersion(val value: Int)

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

@ -8,15 +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:android:viewmodel")
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"))
}

View File

@ -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

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.homeReducer
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) =
homeReducer(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

@ -10,34 +10,39 @@ 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.home.HomeScreenState.*
import app.dapk.st.home.HomeScreenState.Page.Directory
import app.dapk.st.home.HomeScreenState.Page.Profile
import app.dapk.st.directory.state.DirectoryState
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
import app.dapk.st.profile.state.ProfileState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun HomeScreen(homeViewModel: HomeViewModel) {
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)) {
when (state.page) {
Directory -> DirectoryScreen(homeViewModel.directory())
Directory -> DirectoryScreen(directoryState)
Profile -> {
ProfileScreen(homeViewModel.profile()) {
homeViewModel.changePage(Directory)
ProfileScreen(profileState) {
homeState.dispatch(HomeAction.ChangePage(Directory))
}
}
}
@ -47,8 +52,8 @@ internal fun HomeScreen(homeViewModel: HomeViewModel) {
}
SignedOut -> {
LoginScreen(homeViewModel.login()) {
homeViewModel.loggedIn()
LoginScreen(loginState) {
homeState.dispatch(HomeAction.LoggedIn)
}
}
}
@ -56,7 +61,7 @@ internal fun HomeScreen(homeViewModel: HomeViewModel) {
@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)) {
@ -67,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))
}
},
)
@ -86,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,131 +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 directory() = directoryState
fun login() = loginState
fun profile() = profileState
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)
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 = {
},
confirmButton = {
TextButton(onClick = { homeViewModel.clearCache() }) {
Text(text = "Clear cache".uppercase())
}
},
)
}
}
@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 = { homeState.dispatch(HomeAction.ClearCache) }) {
Text(text = "Clear cache".uppercase())
}
},
)
}

View File

@ -0,0 +1,21 @@
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
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 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
}

View File

@ -0,0 +1,124 @@
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 homeReducer(
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.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
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)
} else {
dispatch(HomeAction.UpdateState(HomeScreenState.SignedOut))
}
},
async(HomeAction.InitialHome::class) {
val me = chatEngine.me(forceRefresh = false)
dispatch(HomeAction.UpdateToSignedIn(me))
listenForInviteChanges(chatEngine, jobBag)
},
async(HomeAction.LoggedIn::class) {
dispatch(HomeAction.InitialHome)
eventEmitter.invoke(HomeEvent.OnShowContent)
},
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 ->
change { _, state ->
when (state) {
is HomeScreenState.SignedIn -> when (action.page) {
state.page -> state
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)
},
)
}
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

@ -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

@ -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<ApplicationPreferences>()
fun givenVersion() = coEvery { instance.readVersion() }.delegateReturn()
}

View File

@ -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<BetaVersionUpgradeUseCase>()
}

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())
}

View File

@ -144,7 +144,6 @@ internal fun settingsReducer(
}
Ignored -> {
nothing()
}
ToggleDynamicTheme -> async {

@ -1 +1 @@
Subproject commit ea31ab26de443ed5e6bb67ce594e3ce8d5f04ff3
Subproject commit d596949ac2b923b02da55ddd78e2e26dc46af82a