Merge pull request #260 from ouchadam/tech/profile-reducer

Porting ProfileViewModel to reducer
This commit is contained in:
Adam Brown 2022-11-07 21:22:03 +00:00 committed by GitHub
commit 8885406a74
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 358 additions and 182 deletions

View File

@ -12,13 +12,10 @@ import java.io.InputStream
class FakeChatEngine : ChatEngine by mockk() { class FakeChatEngine : ChatEngine by mockk() {
fun givenMessages(roomId: RoomId, disableReadReceipts: Boolean) = every { messages(roomId, disableReadReceipts) }.delegateReturn() fun givenMessages(roomId: RoomId, disableReadReceipts: Boolean) = every { messages(roomId, disableReadReceipts) }.delegateReturn()
fun givenDirectory() = every { directory() }.delegateReturn() fun givenDirectory() = every { directory() }.delegateReturn()
fun givenImportKeys(inputStream: InputStream, passphrase: String) = coEvery { inputStream.importRoomKeys(passphrase) }.delegateReturn() fun givenImportKeys(inputStream: InputStream, passphrase: String) = coEvery { inputStream.importRoomKeys(passphrase) }.delegateReturn()
fun givenNotificationsInvites() = every { notificationsInvites() }.delegateEmit() fun givenNotificationsInvites() = every { notificationsInvites() }.delegateEmit()
fun givenNotificationsMessages() = every { notificationsMessages() }.delegateEmit() fun givenNotificationsMessages() = every { notificationsMessages() }.delegateEmit()
fun givenInvites() = every { invites() }.delegateEmit()
fun givenMe(forceRefresh: Boolean) = coEvery { me(forceRefresh) }.delegateReturn()
} }

View File

@ -25,4 +25,8 @@ class JobBag {
jobs.remove(key.java.canonicalName)?.cancel() jobs.remove(key.java.canonicalName)?.cancel()
} }
fun cancelAll() {
jobs.values.forEach { it.cancel() }
}
} }

View File

@ -3,7 +3,6 @@ applyAndroidComposeLibraryModule(project)
dependencies { dependencies {
implementation project(":chat-engine") implementation project(":chat-engine")
implementation project(":domains:android:compose-core") implementation project(":domains:android:compose-core")
implementation project(":domains:android:viewmodel")
implementation project(":domains:state") implementation project(":domains:state")
implementation project(":features:messenger") implementation project(":features:messenger")
implementation project(":core") implementation project(":core")
@ -16,7 +15,6 @@ dependencies {
androidImportFixturesWorkaround(project, project(":core")) androidImportFixturesWorkaround(project, project(":core"))
androidImportFixturesWorkaround(project, project(":domains:state")) androidImportFixturesWorkaround(project, project(":domains:state"))
androidImportFixturesWorkaround(project, project(":domains:store")) androidImportFixturesWorkaround(project, project(":domains:store"))
androidImportFixturesWorkaround(project, project(":domains:android:viewmodel"))
androidImportFixturesWorkaround(project, project(":domains:android:stub")) androidImportFixturesWorkaround(project, project(":domains:android:stub"))
androidImportFixturesWorkaround(project, project(":chat-engine")) androidImportFixturesWorkaround(project, project(":chat-engine"))
} }

View File

@ -5,7 +5,7 @@ import app.dapk.st.directory.state.DirectoryState
import app.dapk.st.domain.StoreModule import app.dapk.st.domain.StoreModule
import app.dapk.st.engine.ChatEngine import app.dapk.st.engine.ChatEngine
import app.dapk.st.login.LoginViewModel import app.dapk.st.login.LoginViewModel
import app.dapk.st.profile.ProfileViewModel import app.dapk.st.profile.state.ProfileState
class HomeModule( class HomeModule(
private val chatEngine: ChatEngine, private val chatEngine: ChatEngine,
@ -13,13 +13,13 @@ class HomeModule(
val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase, val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase,
) : ProvidableModule { ) : ProvidableModule {
internal fun homeViewModel(directory: DirectoryState, login: LoginViewModel, profileViewModel: ProfileViewModel): HomeViewModel { internal fun homeViewModel(directory: DirectoryState, login: LoginViewModel, profile: ProfileState): HomeViewModel {
return HomeViewModel( return HomeViewModel(
chatEngine, chatEngine,
storeModule.credentialsStore(), storeModule.credentialsStore(),
directory, directory,
login, login,
profileViewModel, profile,
storeModule.cacheCleaner(), storeModule.cacheCleaner(),
betaVersionUpgradeUseCase, betaVersionUpgradeUseCase,
) )

View File

@ -10,7 +10,8 @@ import app.dapk.st.home.HomeScreenState.*
import app.dapk.st.login.LoginViewModel import app.dapk.st.login.LoginViewModel
import app.dapk.st.matrix.common.CredentialsStore import app.dapk.st.matrix.common.CredentialsStore
import app.dapk.st.matrix.common.isSignedIn import app.dapk.st.matrix.common.isSignedIn
import app.dapk.st.profile.ProfileViewModel import app.dapk.st.profile.state.ProfileAction
import app.dapk.st.profile.state.ProfileState
import app.dapk.st.viewmodel.DapkViewModel import app.dapk.st.viewmodel.DapkViewModel
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -24,7 +25,7 @@ internal class HomeViewModel(
private val credentialsProvider: CredentialsStore, private val credentialsProvider: CredentialsStore,
private val directoryState: DirectoryState, private val directoryState: DirectoryState,
private val loginViewModel: LoginViewModel, private val loginViewModel: LoginViewModel,
private val profileViewModel: ProfileViewModel, private val profileState: ProfileState,
private val cacheCleaner: StoreCleaner, private val cacheCleaner: StoreCleaner,
private val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase, private val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase,
) : DapkViewModel<HomeScreenState, HomeEvent>( ) : DapkViewModel<HomeScreenState, HomeEvent>(
@ -35,7 +36,7 @@ internal class HomeViewModel(
fun directory() = directoryState fun directory() = directoryState
fun login() = loginViewModel fun login() = loginViewModel
fun profile() = profileViewModel fun profile() = profileState
fun start() { fun start() {
viewModelScope.launch { viewModelScope.launch {
@ -125,7 +126,7 @@ internal class HomeViewModel(
Page.Profile -> { Page.Profile -> {
directoryState.dispatch(ComponentLifecycle.OnGone) directoryState.dispatch(ComponentLifecycle.OnGone)
profileViewModel.reset() profileState.dispatch(ProfileAction.Reset)
} }
} }
} }

View File

@ -25,7 +25,7 @@ class MainActivity : DapkActivity() {
private val directoryViewModel by state { module<DirectoryModule>().directoryState() } private val directoryViewModel by state { module<DirectoryModule>().directoryState() }
private val loginViewModel by viewModel { module<LoginModule>().loginViewModel() } private val loginViewModel by viewModel { module<LoginModule>().loginViewModel() }
private val profileViewModel by viewModel { module<ProfileModule>().profileViewModel() } private val profileViewModel by state { module<ProfileModule>().profileState() }
private val homeViewModel by viewModel { module<HomeModule>().homeViewModel(directoryViewModel, loginViewModel, profileViewModel) } private val homeViewModel by viewModel { module<HomeModule>().homeViewModel(directoryViewModel, loginViewModel, profileViewModel) }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {

View File

@ -4,8 +4,17 @@ dependencies {
implementation project(":chat-engine") implementation project(":chat-engine")
implementation project(":features:settings") implementation project(":features:settings")
implementation project(':domains:store') implementation project(':domains:store')
implementation project(':domains:state')
implementation project(":domains:android:compose-core") implementation project(":domains:android:compose-core")
implementation project(":domains:android:viewmodel")
implementation project(":design-library") implementation project(":design-library")
implementation project(":core") implementation project(":core")
kotlinTest(it)
androidImportFixturesWorkaround(project, project(":matrix:common"))
androidImportFixturesWorkaround(project, project(":core"))
androidImportFixturesWorkaround(project, project(":domains:state"))
androidImportFixturesWorkaround(project, project(":domains:store"))
androidImportFixturesWorkaround(project, project(":domains:android:stub"))
androidImportFixturesWorkaround(project, project(":chat-engine"))
} }

View File

@ -1,16 +1,21 @@
package app.dapk.st.profile package app.dapk.st.profile
import app.dapk.st.core.JobBag
import app.dapk.st.core.ProvidableModule import app.dapk.st.core.ProvidableModule
import app.dapk.st.core.createStateViewModel
import app.dapk.st.core.extensions.ErrorTracker import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.engine.ChatEngine import app.dapk.st.engine.ChatEngine
import app.dapk.st.profile.state.ProfileState
import app.dapk.st.profile.state.ProfileUseCase
import app.dapk.st.profile.state.profileReducer
class ProfileModule( class ProfileModule(
private val chatEngine: ChatEngine, private val chatEngine: ChatEngine,
private val errorTracker: ErrorTracker, private val errorTracker: ErrorTracker,
) : ProvidableModule { ) : ProvidableModule {
fun profileViewModel(): ProfileViewModel { fun profileState(): ProfileState {
return ProfileViewModel(chatEngine, errorTracker) return createStateViewModel { profileReducer(chatEngine, errorTracker, ProfileUseCase(chatEngine, errorTracker), JobBag()) }
} }
} }

View File

@ -20,18 +20,22 @@ import androidx.compose.ui.unit.dp
import app.dapk.st.core.Lce import app.dapk.st.core.Lce
import app.dapk.st.core.LifecycleEffect import app.dapk.st.core.LifecycleEffect
import app.dapk.st.core.components.CenteredLoading import app.dapk.st.core.components.CenteredLoading
import app.dapk.st.core.page.PageAction
import app.dapk.st.design.components.* import app.dapk.st.design.components.*
import app.dapk.st.engine.RoomInvite import app.dapk.st.engine.RoomInvite
import app.dapk.st.engine.RoomInvite.InviteMeta import app.dapk.st.engine.RoomInvite.InviteMeta
import app.dapk.st.profile.state.Page
import app.dapk.st.profile.state.ProfileAction
import app.dapk.st.profile.state.ProfileState
import app.dapk.st.settings.SettingsActivity import app.dapk.st.settings.SettingsActivity
@Composable @Composable
fun ProfileScreen(viewModel: ProfileViewModel, onTopLevelBack: () -> Unit) { fun ProfileScreen(viewModel: ProfileState, onTopLevelBack: () -> Unit) {
viewModel.ObserveEvents() viewModel.ObserveEvents()
LifecycleEffect( LifecycleEffect(
onStart = { viewModel.start() }, onStart = { viewModel.dispatch(ProfileAction.ComponentLifecycle.Visible) },
onStop = { viewModel.stop() } onStop = { viewModel.dispatch(ProfileAction.ComponentLifecycle.Gone) }
) )
val context = LocalContext.current val context = LocalContext.current
@ -39,11 +43,11 @@ fun ProfileScreen(viewModel: ProfileViewModel, onTopLevelBack: () -> Unit) {
val onNavigate: (SpiderPage<out Page>?) -> Unit = { val onNavigate: (SpiderPage<out Page>?) -> Unit = {
when (it) { when (it) {
null -> onTopLevelBack() null -> onTopLevelBack()
else -> viewModel.goTo(it) else -> viewModel.dispatch(PageAction.GoTo(it))
} }
} }
Spider(currentPage = viewModel.state.page, onNavigate = onNavigate) { Spider(currentPage = viewModel.current.state1.page, onNavigate = onNavigate) {
item(Page.Routes.profile) { item(Page.Routes.profile) {
ProfilePage(context, viewModel, it) ProfilePage(context, viewModel, it)
} }
@ -54,7 +58,7 @@ fun ProfileScreen(viewModel: ProfileViewModel, onTopLevelBack: () -> Unit) {
} }
@Composable @Composable
private fun ProfilePage(context: Context, viewModel: ProfileViewModel, profile: Page.Profile) { private fun ProfilePage(context: Context, viewModel: ProfileState, profile: Page.Profile) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -67,7 +71,7 @@ private fun ProfilePage(context: Context, viewModel: ProfileViewModel, profile:
when (val state = profile.content) { when (val state = profile.content) {
is Lce.Loading -> CenteredLoading() is Lce.Loading -> CenteredLoading()
is Lce.Error -> GenericError { viewModel.start() } is Lce.Error -> GenericError { viewModel.dispatch(ProfileAction.ComponentLifecycle.Visible) }
is Lce.Content -> { is Lce.Content -> {
val configuration = LocalConfiguration.current val configuration = LocalConfiguration.current
val content = state.value val content = state.value
@ -111,7 +115,7 @@ private fun ProfilePage(context: Context, viewModel: ProfileViewModel, profile:
TextRow( TextRow(
title = "Invitations", title = "Invitations",
content = "${content.invitationsCount} pending", content = "${content.invitationsCount} pending",
onClick = { viewModel.goToInvitations() } onClick = { viewModel.dispatch(ProfileAction.GoToInvitations) }
) )
} }
} }
@ -119,7 +123,7 @@ private fun ProfilePage(context: Context, viewModel: ProfileViewModel, profile:
} }
@Composable @Composable
private fun SpiderItemScope.Invitations(viewModel: ProfileViewModel, invitations: Page.Invitations) { private fun SpiderItemScope.Invitations(viewModel: ProfileState, invitations: Page.Invitations) {
when (val state = invitations.content) { when (val state = invitations.content) {
is Lce.Loading -> CenteredLoading() is Lce.Loading -> CenteredLoading()
is Lce.Content -> { is Lce.Content -> {
@ -133,11 +137,11 @@ private fun SpiderItemScope.Invitations(viewModel: ProfileViewModel, invitations
TextRow(title = text, includeDivider = false) { TextRow(title = text, includeDivider = false) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Row { Row {
Button(modifier = Modifier.weight(1f), onClick = { viewModel.rejectRoomInvite(it.roomId) }) { Button(modifier = Modifier.weight(1f), onClick = { viewModel.dispatch(ProfileAction.RejectRoomInvite(it.roomId)) }) {
Text("Reject".uppercase()) Text("Reject".uppercase())
} }
Spacer(modifier = Modifier.fillMaxWidth(0.1f)) Spacer(modifier = Modifier.fillMaxWidth(0.1f))
Button(modifier = Modifier.weight(1f), onClick = { viewModel.acceptRoomInvite(it.roomId) }) { Button(modifier = Modifier.weight(1f), onClick = { viewModel.dispatch(ProfileAction.AcceptRoomInvite(it.roomId)) }) {
Text("Accept".uppercase()) Text("Accept".uppercase())
} }
} }
@ -154,7 +158,7 @@ private fun SpiderItemScope.Invitations(viewModel: ProfileViewModel, invitations
private fun RoomInvite.inviterName() = this.from.displayName?.let { "$it (${this.from.id.value})" } ?: this.from.id.value private fun RoomInvite.inviterName() = this.from.displayName?.let { "$it (${this.from.id.value})" } ?: this.from.id.value
@Composable @Composable
private fun ProfileViewModel.ObserveEvents() { private fun ProfileState.ObserveEvents() {
// StartObserving { // StartObserving {
// this@ObserveEvents.events.launch { // this@ObserveEvents.events.launch {
// when (it) { // when (it) {

View File

@ -1,142 +0,0 @@
package app.dapk.st.profile
import android.util.Log
import androidx.lifecycle.viewModelScope
import app.dapk.st.core.Lce
import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.design.components.SpiderPage
import app.dapk.st.engine.ChatEngine
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.viewmodel.DapkViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
class ProfileViewModel(
private val chatEngine: ChatEngine,
private val errorTracker: ErrorTracker,
) : DapkViewModel<ProfileScreenState, ProfileEvent>(
ProfileScreenState(SpiderPage(Page.Routes.profile, "Profile", null, Page.Profile(Lce.Loading()), hasToolbar = false))
) {
private var currentPageJob: Job? = null
fun start() {
goToProfile()
}
private fun goToProfile() {
combine(
flow {
val result = runCatching { chatEngine.me(forceRefresh = true) }
.onFailure { errorTracker.track(it, "Loading profile") }
emit(result)
},
chatEngine.invites(),
transform = { me, invites -> me to invites }
)
.onEach { (me, invites) ->
updatePageState<Page.Profile> {
when (me.isSuccess) {
true -> copy(content = Lce.Content(Page.Profile.Content(me.getOrThrow(), invites.size)))
false -> copy(content = Lce.Error(me.exceptionOrNull()!!))
}
}
}
.launchPageJob()
}
fun goToInvitations() {
updateState { copy(page = SpiderPage(Page.Routes.invitation, "Invitations", Page.Routes.profile, Page.Invitations(Lce.Loading()))) }
chatEngine.invites()
.onEach {
updatePageState<Page.Invitations> {
copy(content = Lce.Content(it))
}
}
.launchPageJob()
}
fun goTo(page: SpiderPage<out Page>) {
currentPageJob?.cancel()
updateState { copy(page = page) }
when (page.state) {
is Page.Invitations -> goToInvitations()
is Page.Profile -> goToProfile()
}
}
private fun <T> Flow<T>.launchPageJob() {
currentPageJob?.cancel()
currentPageJob = this.launchIn(viewModelScope)
}
fun updateDisplayName() {
// TODO
}
fun updateAvatar() {
// TODO
}
fun acceptRoomInvite(roomId: RoomId) {
launchCatching { chatEngine.joinRoom(roomId) }.fold(
onError = {}
)
}
fun rejectRoomInvite(roomId: RoomId) {
launchCatching { chatEngine.rejectJoinRoom(roomId) }.fold(
onError = {
Log.e("!!!", it.message, it)
}
)
}
@Suppress("UNCHECKED_CAST")
private inline fun <reified S : Page> updatePageState(crossinline block: S.() -> S) {
val page = state.page
val currentState = page.state
require(currentState is S)
updateState { copy(page = (page as SpiderPage<S>).copy(state = block(page.state))) }
}
fun reset() {
when (state.page.state) {
is Page.Invitations -> updateState {
ProfileScreenState(
SpiderPage(
Page.Routes.profile,
"Profile",
null,
Page.Profile(Lce.Loading()),
hasToolbar = false
)
)
}
is Page.Profile -> {
// do nothing
}
}
}
fun stop() {
currentPageJob?.cancel()
}
}
fun <S, VE, T> DapkViewModel<S, VE>.launchCatching(block: suspend () -> T): LaunchCatching<T> {
return object : LaunchCatching<T> {
override fun fold(onSuccess: (T) -> Unit, onError: (Throwable) -> Unit) {
viewModelScope.launch { runCatching { block() }.fold(onSuccess, onError) }
}
}
}
interface LaunchCatching<T> {
fun fold(onSuccess: (T) -> Unit = {}, onError: (Throwable) -> Unit = {})
}

View File

@ -0,0 +1,17 @@
package app.dapk.st.profile.state
import app.dapk.st.matrix.common.RoomId
import app.dapk.state.Action
sealed interface ProfileAction : Action {
sealed interface ComponentLifecycle : ProfileAction {
object Visible : ComponentLifecycle
object Gone : ComponentLifecycle
}
object GoToInvitations : ProfileAction
data class AcceptRoomInvite(val roomId: RoomId) : ProfileAction
data class RejectRoomInvite(val roomId: RoomId) : ProfileAction
object Reset : ProfileAction
}

View File

@ -0,0 +1,87 @@
package app.dapk.st.profile.state
import app.dapk.st.core.JobBag
import app.dapk.st.core.Lce
import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.core.page.PageAction
import app.dapk.st.core.page.PageStateChange
import app.dapk.st.core.page.createPageReducer
import app.dapk.st.core.page.withPageContext
import app.dapk.st.design.components.SpiderPage
import app.dapk.st.engine.ChatEngine
import app.dapk.state.async
import app.dapk.state.createReducer
import app.dapk.state.sideEffect
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
fun profileReducer(
chatEngine: ChatEngine,
errorTracker: ErrorTracker,
profileUseCase: ProfileUseCase,
jobBag: JobBag,
) = createPageReducer(
initialPage = SpiderPage<Page>(Page.Routes.profile, "Profile", null, Page.Profile(Lce.Loading()), hasToolbar = false),
factory = {
createReducer(
initialState = Unit,
async(ProfileAction.ComponentLifecycle::class) {
when (it) {
ProfileAction.ComponentLifecycle.Visible -> {
jobBag.replace(Page.Profile::class, profileUseCase.content().onEach { content ->
withPageContext<Page.Profile> {
pageDispatch(PageStateChange.UpdatePage(it.copy(content = content)))
}
}.launchIn(coroutineScope))
}
ProfileAction.ComponentLifecycle.Gone -> jobBag.cancelAll()
}
},
async(ProfileAction.GoToInvitations::class) {
dispatch(PageAction.GoTo(SpiderPage(Page.Routes.invitation, "Invitations", Page.Routes.profile, Page.Invitations(Lce.Loading()))))
jobBag.replace(Page.Invitations::class, chatEngine.invites()
.onEach { invitations ->
withPageContext<Page.Invitations> {
pageDispatch(PageStateChange.UpdatePage(it.copy(content = Lce.Content(invitations))))
}
}
.launchIn(coroutineScope))
},
async(ProfileAction.Reset::class) {
when (rawPage().state) {
is Page.Invitations -> {
dispatch(PageAction.GoTo(SpiderPage<Page>(Page.Routes.profile, "Profile", null, Page.Profile(Lce.Loading()), hasToolbar = false)))
}
is Page.Profile -> {
// do nothing
}
}
},
sideEffect(ProfileAction.AcceptRoomInvite::class) { action, _ ->
kotlin.runCatching { chatEngine.joinRoom(action.roomId) }.fold(
onFailure = { errorTracker.track(it) },
onSuccess = {}
)
},
sideEffect(ProfileAction.RejectRoomInvite::class) { action, _ ->
kotlin.runCatching { chatEngine.rejectJoinRoom(action.roomId) }.fold(
onFailure = { errorTracker.track(it) },
onSuccess = {}
)
},
sideEffect(PageStateChange.ChangePage::class) { action, _ ->
jobBag.cancel(action.previous.state::class)
},
)
}
)

View File

@ -1,14 +1,14 @@
package app.dapk.st.profile package app.dapk.st.profile.state
import app.dapk.st.core.Lce import app.dapk.st.core.Lce
import app.dapk.st.core.State
import app.dapk.st.core.page.PageContainer
import app.dapk.st.design.components.Route import app.dapk.st.design.components.Route
import app.dapk.st.design.components.SpiderPage
import app.dapk.st.engine.Me import app.dapk.st.engine.Me
import app.dapk.st.engine.RoomInvite import app.dapk.st.engine.RoomInvite
import app.dapk.state.Combined2
data class ProfileScreenState( typealias ProfileState = State<Combined2<PageContainer<Page>, Unit>, Unit>
val page: SpiderPage<out Page>,
)
sealed interface Page { sealed interface Page {
data class Profile(val content: Lce<Content>) : Page { data class Profile(val content: Lce<Content>) : Page {
@ -25,8 +25,3 @@ sealed interface Page {
val invitation = Route<Invitations>("Invitations") val invitation = Route<Invitations>("Invitations")
} }
} }
sealed interface ProfileEvent {
}

View File

@ -0,0 +1,35 @@
package app.dapk.st.profile.state
import app.dapk.st.core.Lce
import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.engine.ChatEngine
import app.dapk.st.engine.Me
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
class ProfileUseCase(
private val chatEngine: ChatEngine,
private val errorTracker: ErrorTracker,
) {
private var meCache: Me? = null
fun content(): Flow<Lce<Page.Profile.Content>> {
return combine(fetchMe(), chatEngine.invites(), transform = { me, invites -> me to invites }).map { (me, invites) ->
when (me.isSuccess) {
true -> Lce.Content(Page.Profile.Content(me.getOrThrow(), invites.size))
false -> Lce.Error(me.exceptionOrNull()!!)
}
}
}
private fun fetchMe() = flow {
meCache?.let { emit(Result.success(it)) }
val result = runCatching { chatEngine.me(forceRefresh = true) }
.onFailure { errorTracker.track(it, "Loading profile") }
.onSuccess { meCache = it }
emit(result)
}
}

View File

@ -0,0 +1,121 @@
package app.dapk.st.profile.state
import app.dapk.st.core.Lce
import app.dapk.st.core.page.PageAction
import app.dapk.st.core.page.PageContainer
import app.dapk.st.core.page.PageStateChange
import app.dapk.st.design.components.SpiderPage
import app.dapk.st.engine.Me
import app.dapk.st.matrix.common.HomeServerUrl
import app.dapk.state.Combined2
import fake.FakeChatEngine
import fake.FakeErrorTracker
import fake.FakeJobBag
import fixture.aRoomId
import fixture.aRoomInvite
import fixture.aUserId
import io.mockk.every
import io.mockk.mockk
import org.junit.Test
import test.assertOnlyDispatches
import test.delegateEmit
import test.testReducer
private val INITIAL_PROFILE_PAGE = SpiderPage(Page.Routes.profile, "Profile", null, Page.Profile(Lce.Loading()), hasToolbar = false)
private val INITIAL_INVITATION_PAGE = SpiderPage(Page.Routes.invitation, "Invitations", Page.Routes.profile, Page.Invitations(Lce.Loading()), hasToolbar = true)
private val A_ROOM_ID = aRoomId()
private val A_PROFILE_CONTENT = Page.Profile.Content(Me(aUserId(), null, null, HomeServerUrl("ignored")), invitationsCount = 4)
class ProfileReducerTest {
private val fakeChatEngine = FakeChatEngine()
private val fakeErrorTracker = FakeErrorTracker()
private val fakeProfileUseCase = FakeProfileUseCase()
private val fakeJobBag = FakeJobBag()
private val runReducerTest = testReducer { _: (Unit) -> Unit ->
profileReducer(
fakeChatEngine,
fakeErrorTracker,
fakeProfileUseCase.instance,
fakeJobBag.instance,
)
}
@Test
fun `initial state is empty loading`() = runReducerTest {
assertInitialState(pageState(SpiderPage(Page.Routes.profile, "Profile", null, Page.Profile(Lce.Loading()), hasToolbar = false)))
}
@Test
fun `given on Profile page, when Reset, then does nothing`() = runReducerTest {
reduce(ProfileAction.Reset)
assertNoChanges()
}
@Test
fun `when Visible, then updates Profile page content`() = runReducerTest {
fakeJobBag.instance.expect { it.replace(Page.Profile::class, any()) }
fakeProfileUseCase.givenContent().emits(Lce.Content(A_PROFILE_CONTENT))
reduce(ProfileAction.ComponentLifecycle.Visible)
assertOnlyDispatches(
PageStateChange.UpdatePage(INITIAL_PROFILE_PAGE.state.copy(Lce.Content(A_PROFILE_CONTENT))),
)
}
@Test
fun `when GoToInvitations, then goes to Invitations page and updates content`() = runReducerTest {
fakeJobBag.instance.expect { it.replace(Page.Invitations::class, any()) }
val goToInvitations = PageAction.GoTo(INITIAL_INVITATION_PAGE)
actionSideEffect(goToInvitations) { pageState(goToInvitations.page) }
val content = listOf(aRoomInvite())
fakeChatEngine.givenInvites().emits(content)
reduce(ProfileAction.GoToInvitations)
assertOnlyDispatches(
PageAction.GoTo(INITIAL_INVITATION_PAGE),
PageStateChange.UpdatePage(INITIAL_INVITATION_PAGE.state.copy(Lce.Content(content))),
)
}
@Test
fun `given on Invitation page, when Reset, then goes to Profile page`() = runReducerTest {
setState(pageState(INITIAL_INVITATION_PAGE))
reduce(ProfileAction.Reset)
assertOnlyDispatches(PageAction.GoTo(INITIAL_PROFILE_PAGE))
}
@Test
fun `when RejectRoomInvite, then rejects room`() = runReducerTest {
fakeChatEngine.expect { it.rejectJoinRoom(A_ROOM_ID) }
reduce(ProfileAction.RejectRoomInvite(A_ROOM_ID))
}
@Test
fun `when AcceptRoomInvite, then joins room`() = runReducerTest {
fakeChatEngine.expect { it.joinRoom(A_ROOM_ID) }
reduce(ProfileAction.AcceptRoomInvite(A_ROOM_ID))
}
@Test
fun `when ChangePage, then cancels any previous page jobs`() = runReducerTest {
fakeJobBag.instance.expect { it.cancel(Page.Invitations::class) }
reduce(PageStateChange.ChangePage(INITIAL_INVITATION_PAGE, INITIAL_PROFILE_PAGE))
}
}
private fun <P> pageState(page: SpiderPage<out P>) = Combined2(PageContainer(page), Unit)
class FakeProfileUseCase {
val instance = mockk<ProfileUseCase>()
fun givenContent() = every { instance.content() }.delegateEmit()
}

View File

@ -0,0 +1,45 @@
package app.dapk.st.profile.state
import app.dapk.st.core.Lce
import app.dapk.st.engine.Me
import app.dapk.st.matrix.common.HomeServerUrl
import fake.FakeChatEngine
import fake.FakeErrorTracker
import fixture.aRoomInvite
import fixture.aUserId
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
private val A_ME = Me(aUserId(), null, null, HomeServerUrl("ignored"))
private val AN_INVITES_LIST = listOf(aRoomInvite(), aRoomInvite(), aRoomInvite(), aRoomInvite())
private val AN_ERROR = RuntimeException()
class ProfileUseCaseTest {
private val fakeChatEngine = FakeChatEngine()
private val fakeErrorTracker = FakeErrorTracker()
private val useCase = ProfileUseCase(fakeChatEngine, fakeErrorTracker)
@Test
fun `given me and invites, when fetching content, then emits content`() = runTest {
fakeChatEngine.givenMe(forceRefresh = true).returns(A_ME)
fakeChatEngine.givenInvites().emits(AN_INVITES_LIST)
val result = useCase.content().first()
result shouldBeEqualTo Lce.Content(Page.Profile.Content(A_ME, invitationsCount = AN_INVITES_LIST.size))
}
@Test
fun `given me fails, when fetching content, then emits error`() = runTest {
fakeChatEngine.givenMe(forceRefresh = true).throws(AN_ERROR)
fakeChatEngine.givenInvites().emits(emptyList())
val result = useCase.content().first()
result shouldBeEqualTo Lce.Error(AN_ERROR)
}
}