Browse Source

Room : rename the flows

pull/794/head
ganfra 1 year ago
parent
commit
d71a025f9d
  1. 4
      app/src/main/kotlin/io/element/android/x/MainNode.kt
  2. 14
      appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
  3. 177
      appnav/src/main/kotlin/io/element/android/appnav/room/AwaitRoomNode.kt
  4. 126
      appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomNodeView.kt
  5. 35
      appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomState.kt
  6. 158
      appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt
  7. 179
      appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt
  8. 18
      appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt

4
app/src/main/kotlin/io/element/android/x/MainNode.kt

@ -28,7 +28,7 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import io.element.android.appnav.LoggedInFlowNode import io.element.android.appnav.LoggedInFlowNode
import io.element.android.appnav.room.RoomFlowNode import io.element.android.appnav.room.RoomLoadedFlowNode
import io.element.android.appnav.RootFlowNode import io.element.android.appnav.RootFlowNode
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
@ -67,7 +67,7 @@ class MainNode(
} }
} }
private val roomFlowNodeCallback = object : RoomFlowNode.LifecycleCallback { private val roomFlowNodeCallback = object : RoomLoadedFlowNode.LifecycleCallback {
override fun onFlowCreated(identifier: String, room: MatrixRoom) { override fun onFlowCreated(identifier: String, room: MatrixRoom) {
val component = bindings<RoomComponent.ParentBindings>().roomComponentBuilder().room(room).build() val component = bindings<RoomComponent.ParentBindings>().roomComponentBuilder().room(room).build()
mainDaggerComponentOwner.addComponent(identifier, component) mainDaggerComponentOwner.addComponent(identifier, component)

14
appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt

@ -42,8 +42,8 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.appnav.loggedin.LoggedInNode import io.element.android.appnav.loggedin.LoggedInNode
import io.element.android.appnav.room.AwaitRoomNode
import io.element.android.appnav.room.RoomFlowNode import io.element.android.appnav.room.RoomFlowNode
import io.element.android.appnav.room.RoomLoadedFlowNode
import io.element.android.features.analytics.api.AnalyticsEntryPoint import io.element.android.features.analytics.api.AnalyticsEntryPoint
import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.invitelist.api.InviteListEntryPoint import io.element.android.features.invitelist.api.InviteListEntryPoint
@ -208,7 +208,7 @@ class LoggedInFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data class Room( data class Room(
val roomId: RoomId, val roomId: RoomId,
val initialElement: RoomFlowNode.NavTarget = RoomFlowNode.NavTarget.Messages val initialElement: RoomLoadedFlowNode.NavTarget = RoomLoadedFlowNode.NavTarget.Messages
) : NavTarget ) : NavTarget
@Parcelize @Parcelize
@ -256,7 +256,7 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
override fun onRoomSettingsClicked(roomId: RoomId) { override fun onRoomSettingsClicked(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId, initialElement = RoomFlowNode.NavTarget.RoomDetails)) backstack.push(NavTarget.Room(roomId, initialElement = RoomLoadedFlowNode.NavTarget.RoomDetails))
} }
override fun onReportBugClicked() { override fun onReportBugClicked() {
@ -270,13 +270,13 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
is NavTarget.Room -> { is NavTarget.Room -> {
val nodeLifecycleCallbacks = plugins<NodeLifecycleCallback>() val nodeLifecycleCallbacks = plugins<NodeLifecycleCallback>()
val callback = object : RoomFlowNode.Callback { val callback = object : RoomLoadedFlowNode.Callback {
override fun onForwardedToSingleRoom(roomId: RoomId) { override fun onForwardedToSingleRoom(roomId: RoomId) {
coroutineScope.launch { attachRoom(roomId) } coroutineScope.launch { attachRoom(roomId) }
} }
} }
val inputs = AwaitRoomNode.Inputs(roomId = navTarget.roomId, initialElement = navTarget.initialElement) val inputs = RoomFlowNode.Inputs(roomId = navTarget.roomId, initialElement = navTarget.initialElement)
createNode<AwaitRoomNode>(buildContext, plugins = listOf(inputs, callback) + nodeLifecycleCallbacks) createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, callback) + nodeLifecycleCallbacks)
} }
NavTarget.Settings -> { NavTarget.Settings -> {
val callback = object : PreferencesEntryPoint.Callback { val callback = object : PreferencesEntryPoint.Callback {
@ -334,7 +334,7 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
} }
suspend fun attachRoom(roomId: RoomId): AwaitRoomNode { suspend fun attachRoom(roomId: RoomId): RoomFlowNode {
return attachChild { return attachChild {
backstack.singleTop(NavTarget.RoomList) backstack.singleTop(NavTarget.RoomList)
backstack.push(NavTarget.Room(roomId)) backstack.push(NavTarget.Room(roomId))

177
appnav/src/main/kotlin/io/element/android/appnav/room/AwaitRoomNode.kt

@ -1,177 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.appnav.room
import android.os.Parcelable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.appnav.NodeLifecycleCallback
import io.element.android.appnav.safeRoot
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.theme.placeholderBackground
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.theme.ElementTheme
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
class AwaitRoomNode @AssistedInject constructor(
@Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
awaitRoomStateFlowFactory: AwaitRoomStateFlowFactory,
) :
BackstackNode<AwaitRoomNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Loading,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
data class Inputs(
val roomId: RoomId,
val initialElement: RoomFlowNode.NavTarget = RoomFlowNode.NavTarget.Messages,
) : NodeInputs
private val inputs: Inputs = inputs()
private val awaitRoomStateFlow = awaitRoomStateFlowFactory.create(lifecycleScope, inputs.roomId)
sealed interface NavTarget : Parcelable {
@Parcelize
object Loading : NavTarget
@Parcelize
object Loaded : NavTarget
}
init {
awaitRoomStateFlow.onEach { awaitRoomState ->
when (awaitRoomState) {
is AwaitRoomState.Loaded -> backstack.safeRoot(NavTarget.Loaded)
else -> backstack.safeRoot(NavTarget.Loading)
}
}.launchIn(lifecycleScope)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Loaded -> {
val nodeLifecycleCallbacks = plugins<NodeLifecycleCallback>()
val roomFlowNodeCallback = plugins<RoomFlowNode.Callback>()
val awaitRoomState = awaitRoomStateFlow.value
if (awaitRoomState is AwaitRoomState.Loaded) {
val inputs = RoomFlowNode.Inputs(awaitRoomState.room, initialElement = inputs.initialElement)
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback + nodeLifecycleCallbacks)
} else {
loadingNode(buildContext, this::navigateUp)
}
}
NavTarget.Loading -> {
loadingNode(buildContext, this::navigateUp)
}
}
}
private fun loadingNode(buildContext: BuildContext, onBackPressed: () -> Unit) = node(buildContext) { modifier ->
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets.systemBars,
topBar = {
TopAppBar(
modifier = Modifier,
navigationIcon = {
BackButton(onClick = onBackPressed)
},
title = {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(AvatarSize.TimelineRoom.dp)
.align(Alignment.CenterVertically)
.background(color = ElementTheme.colors.placeholderBackground, shape = CircleShape)
)
Spacer(modifier = Modifier.width(8.dp))
PlaceholderAtom(width = 20.dp, height = 7.dp)
Spacer(modifier = Modifier.width(7.dp))
PlaceholderAtom(width = 45.dp, height = 7.dp)
}
},
)
},
content = { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding), contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
},
)
}
@Composable
override fun View(modifier: Modifier) {
Children(
navModel = backstack,
modifier = modifier,
)
}
}

126
appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomNodeView.kt

@ -0,0 +1,126 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.appnav.room
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.appnav.loggedin.LoggedInState
import io.element.android.appnav.loggedin.LoggedInStateProvider
import io.element.android.appnav.loggedin.LoggedInView
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.theme.placeholderBackground
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoadingRoomNodeView(
state: LoadingRoomState,
onBackClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets.systemBars,
topBar = {
TopAppBar(
modifier = Modifier,
navigationIcon = {
BackButton(onClick = onBackClicked)
},
title = {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(AvatarSize.TimelineRoom.dp)
.align(Alignment.CenterVertically)
.background(color = ElementTheme.colors.placeholderBackground, shape = CircleShape)
)
Spacer(modifier = Modifier.width(8.dp))
PlaceholderAtom(width = 20.dp, height = 7.dp)
Spacer(modifier = Modifier.width(7.dp))
PlaceholderAtom(width = 45.dp, height = 7.dp)
}
},
)
},
content = { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp), contentAlignment = Alignment.Center
) {
if (state is LoadingRoomState.Error) {
Text(
text = stringResource(id = CommonStrings.error_unknown),
color = ElementTheme.colors.textSecondary,
fontSize = 14.sp,
)
} else {
CircularProgressIndicator()
}
}
},
)
}
@Preview
@Composable
fun LoadingRoomNodeViewLightPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun LoadingRoomNodeViewDarkPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: LoadingRoomState) {
LoadingRoomNodeView(
state = state,
onBackClicked = {}
)
}

35
appnav/src/main/kotlin/io/element/android/appnav/room/AwaitRoomState.kt → appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomState.kt

@ -16,12 +16,14 @@
package io.element.android.appnav.room package io.element.android.appnav.room
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.asFlow
@ -29,25 +31,36 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject import javax.inject.Inject
sealed interface AwaitRoomState { sealed interface LoadingRoomState {
object Loading : AwaitRoomState object Loading : LoadingRoomState
object Error : AwaitRoomState object Error : LoadingRoomState
data class Loaded(val room: MatrixRoom) : AwaitRoomState data class Loaded(val room: MatrixRoom) : LoadingRoomState
}
open class LoadingRoomStateProvider : PreviewParameterProvider<LoadingRoomState> {
override val values: Sequence<LoadingRoomState>
get() = sequenceOf(
LoadingRoomState.Loading,
LoadingRoomState.Error
)
} }
@SingleIn(SessionScope::class) @SingleIn(SessionScope::class)
class AwaitRoomStateFlowFactory @Inject constructor(private val matrixClient: MatrixClient) { class AwaitRoomStateFlowFactory @Inject constructor(private val matrixClient: MatrixClient) {
fun create(lifecycleScope: CoroutineScope, roomId: RoomId): StateFlow<AwaitRoomState> = suspend { fun create(lifecycleScope: CoroutineScope, roomId: RoomId): StateFlow<LoadingRoomState> =
matrixClient.getRoom(roomId = roomId) getRoomFlow(roomId)
}
.asFlow()
.map { room -> .map { room ->
if (room != null) { if (room != null) {
AwaitRoomState.Loaded(room) LoadingRoomState.Loaded(room)
} else { } else {
AwaitRoomState.Error LoadingRoomState.Error
} }
} }
.stateIn(lifecycleScope, SharingStarted.Eagerly, AwaitRoomState.Loading) .stateIn(lifecycleScope, SharingStarted.Eagerly, LoadingRoomState.Loading)
private fun getRoomFlow(roomId: RoomId): Flow<MatrixRoom?> = suspend {
matrixClient.getRoom(roomId = roomId)
}
.asFlow()
} }

158
appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt

@ -14,166 +14,122 @@
* limitations under the License. * limitations under the License.
*/ */
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.appnav.room package io.element.android.appnav.room
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.composable.Children import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.appnav.NodeLifecycleCallback import io.element.android.appnav.NodeLifecycleCallback
import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.appnav.safeRoot
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import kotlinx.coroutines.flow.distinctUntilChanged
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
class RoomFlowNode @AssistedInject constructor( class RoomFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val messagesEntryPoint: MessagesEntryPoint, awaitRoomStateFlowFactory: AwaitRoomStateFlowFactory,
private val roomDetailsEntryPoint: RoomDetailsEntryPoint, ) :
private val appNavigationStateService: AppNavigationStateService, BackstackNode<RoomFlowNode.NavTarget>(
roomMembershipObserver: RoomMembershipObserver,
) : BackstackNode<RoomFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = plugins.filterIsInstance(Inputs::class.java).first().initialElement, initialElement = NavTarget.Loading,
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins
) { ) {
interface Callback : Plugin {
fun onForwardedToSingleRoom(roomId: RoomId)
}
interface LifecycleCallback : NodeLifecycleCallback {
fun onFlowCreated(identifier: String, room: MatrixRoom) = Unit
fun onFlowReleased(identifier: String, room: MatrixRoom) = Unit
}
data class Inputs( data class Inputs(
val room: MatrixRoom, val roomId: RoomId,
val initialElement: NavTarget = NavTarget.Messages, val initialElement: RoomLoadedFlowNode.NavTarget = RoomLoadedFlowNode.NavTarget.Messages,
) : NodeInputs ) : NodeInputs
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
private val callbacks = plugins.filterIsInstance<Callback>() private val loadingRoomStateStateFlow = awaitRoomStateFlowFactory.create(lifecycleScope, inputs.roomId)
init { sealed interface NavTarget : Parcelable {
lifecycle.subscribe( @Parcelize
onCreate = { object Loading : NavTarget
Timber.v("OnCreate")
plugins<LifecycleCallback>().forEach { it.onFlowCreated(id, inputs.room) } @Parcelize
appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId) object Loaded : NavTarget
fetchRoomMembers()
},
onDestroy = {
Timber.v("OnDestroy")
plugins<LifecycleCallback>().forEach { it.onFlowReleased(id, inputs.room) }
appNavigationStateService.onLeavingRoom(id)
}
)
roomMembershipObserver.updates
.filter { update -> update.roomId == inputs.room.roomId && !update.isUserInRoom }
.onEach {
navigateUp()
}
.launchIn(lifecycleScope)
inputs<Inputs>()
} }
private fun fetchRoomMembers() = lifecycleScope.launch { override fun onBuilt() {
val room = inputs.room super.onBuilt()
room.updateMembers() loadingRoomStateStateFlow
.onFailure { .map {
Timber.e(it, "Fail to fetch members for room ${room.roomId}") it is LoadingRoomState.Loaded
}.onSuccess {
Timber.v("Success fetching members for room ${room.roomId}")
} }
.distinctUntilChanged()
.onEach { isLoaded ->
if (isLoaded) {
backstack.safeRoot(NavTarget.Loaded)
} else {
backstack.safeRoot(NavTarget.Loading)
}
}.launchIn(lifecycleScope)
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Messages -> { NavTarget.Loaded -> {
val callback = object : MessagesEntryPoint.Callback { val nodeLifecycleCallbacks = plugins<NodeLifecycleCallback>()
override fun onRoomDetailsClicked() { val roomFlowNodeCallback = plugins<RoomLoadedFlowNode.Callback>()
backstack.push(NavTarget.RoomDetails) val awaitRoomState = loadingRoomStateStateFlow.value
} if (awaitRoomState is LoadingRoomState.Loaded) {
val inputs = RoomLoadedFlowNode.Inputs(awaitRoomState.room, initialElement = inputs.initialElement)
override fun onUserDataClicked(userId: UserId) { createNode<RoomLoadedFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback + nodeLifecycleCallbacks)
backstack.push(NavTarget.RoomMemberDetails(userId)) } else {
loadingNode(buildContext, this::navigateUp)
} }
override fun onForwardedToSingleRoom(roomId: RoomId) {
callbacks.forEach { it.onForwardedToSingleRoom(roomId) }
}
}
messagesEntryPoint.createNode(this, buildContext, callback)
}
NavTarget.RoomDetails -> {
val inputs = RoomDetailsEntryPoint.Inputs(RoomDetailsEntryPoint.InitialTarget.RoomDetails)
roomDetailsEntryPoint.createNode(this, buildContext, inputs, emptyList())
} }
is NavTarget.RoomMemberDetails -> { NavTarget.Loading -> {
val inputs = RoomDetailsEntryPoint.Inputs(RoomDetailsEntryPoint.InitialTarget.RoomMemberDetails(navTarget.userId)) loadingNode(buildContext, this::navigateUp)
roomDetailsEntryPoint.createNode(this, buildContext, inputs, emptyList())
} }
} }
} }
sealed interface NavTarget : Parcelable { private fun loadingNode(buildContext: BuildContext, onBackClicked: () -> Unit) = node(buildContext) { modifier ->
@Parcelize val loadingRoomState by loadingRoomStateStateFlow.collectAsState()
object Messages : NavTarget LoadingRoomNodeView(
state = loadingRoomState,
@Parcelize modifier = modifier,
object RoomDetails : NavTarget onBackClicked = onBackClicked
)
@Parcelize
data class RoomMemberDetails(val userId: UserId) : NavTarget
} }
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
// Rely on the View Lifecycle instead of the Node Lifecycle,
// because this node enters 'onDestroy' before his children, so it can leads to
// using the room in a child node where it's already closed.
DisposableEffect(Unit) {
inputs.room.open()
onDispose {
inputs.room.close()
}
}
Children( Children(
navModel = backstack, navModel = backstack,
modifier = modifier, modifier = modifier,
transitionHandler = rememberDefaultTransitionHandler(),
) )
} }
} }

179
appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt

@ -0,0 +1,179 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.appnav.room
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.appnav.NodeLifecycleCallback
import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import timber.log.Timber
@ContributesNode(SessionScope::class)
class RoomLoadedFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val messagesEntryPoint: MessagesEntryPoint,
private val roomDetailsEntryPoint: RoomDetailsEntryPoint,
private val appNavigationStateService: AppNavigationStateService,
roomMembershipObserver: RoomMembershipObserver,
) : BackstackNode<RoomLoadedFlowNode.NavTarget>(
backstack = BackStack(
initialElement = plugins.filterIsInstance(Inputs::class.java).first().initialElement,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
interface Callback : Plugin {
fun onForwardedToSingleRoom(roomId: RoomId)
}
interface LifecycleCallback : NodeLifecycleCallback {
fun onFlowCreated(identifier: String, room: MatrixRoom) = Unit
fun onFlowReleased(identifier: String, room: MatrixRoom) = Unit
}
data class Inputs(
val room: MatrixRoom,
val initialElement: NavTarget = NavTarget.Messages,
) : NodeInputs
private val inputs: Inputs = inputs()
private val callbacks = plugins.filterIsInstance<Callback>()
init {
lifecycle.subscribe(
onCreate = {
Timber.v("OnCreate")
plugins<LifecycleCallback>().forEach { it.onFlowCreated(id, inputs.room) }
appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId)
fetchRoomMembers()
},
onDestroy = {
Timber.v("OnDestroy")
plugins<LifecycleCallback>().forEach { it.onFlowReleased(id, inputs.room) }
appNavigationStateService.onLeavingRoom(id)
}
)
roomMembershipObserver.updates
.filter { update -> update.roomId == inputs.room.roomId && !update.isUserInRoom }
.onEach {
navigateUp()
}
.launchIn(lifecycleScope)
inputs<Inputs>()
}
private fun fetchRoomMembers() = lifecycleScope.launch {
val room = inputs.room
room.updateMembers()
.onFailure {
Timber.e(it, "Fail to fetch members for room ${room.roomId}")
}.onSuccess {
Timber.v("Success fetching members for room ${room.roomId}")
}
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Messages -> {
val callback = object : MessagesEntryPoint.Callback {
override fun onRoomDetailsClicked() {
backstack.push(NavTarget.RoomDetails)
}
override fun onUserDataClicked(userId: UserId) {
backstack.push(NavTarget.RoomMemberDetails(userId))
}
override fun onForwardedToSingleRoom(roomId: RoomId) {
callbacks.forEach { it.onForwardedToSingleRoom(roomId) }
}
}
messagesEntryPoint.createNode(this, buildContext, callback)
}
NavTarget.RoomDetails -> {
val inputs = RoomDetailsEntryPoint.Inputs(RoomDetailsEntryPoint.InitialTarget.RoomDetails)
roomDetailsEntryPoint.createNode(this, buildContext, inputs, emptyList())
}
is NavTarget.RoomMemberDetails -> {
val inputs = RoomDetailsEntryPoint.Inputs(RoomDetailsEntryPoint.InitialTarget.RoomMemberDetails(navTarget.userId))
roomDetailsEntryPoint.createNode(this, buildContext, inputs, emptyList())
}
}
}
sealed interface NavTarget : Parcelable {
@Parcelize
object Messages : NavTarget
@Parcelize
object RoomDetails : NavTarget
@Parcelize
data class RoomMemberDetails(val userId: UserId) : NavTarget
}
@Composable
override fun View(modifier: Modifier) {
// Rely on the View Lifecycle instead of the Node Lifecycle,
// because this node enters 'onDestroy' before his children, so it can leads to
// using the room in a child node where it's already closed.
DisposableEffect(Unit) {
inputs.room.open()
onDispose {
inputs.room.close()
}
}
Children(
navModel = backstack,
modifier = modifier,
transitionHandler = rememberDefaultTransitionHandler(),
)
}
}

18
appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt

@ -26,7 +26,7 @@ import com.bumble.appyx.navmodel.backstack.activeElement
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper
import com.google.common.truth.Truth import com.google.common.truth.Truth
import io.element.android.appnav.room.RoomFlowNode import io.element.android.appnav.room.RoomLoadedFlowNode
import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.libraries.architecture.childNode import io.element.android.libraries.architecture.childNode
@ -77,7 +77,7 @@ class RoomFlowNodeTest {
plugins: List<Plugin>, plugins: List<Plugin>,
messagesEntryPoint: MessagesEntryPoint = FakeMessagesEntryPoint(), messagesEntryPoint: MessagesEntryPoint = FakeMessagesEntryPoint(),
roomDetailsEntryPoint: RoomDetailsEntryPoint = FakeRoomDetailsEntryPoint(), roomDetailsEntryPoint: RoomDetailsEntryPoint = FakeRoomDetailsEntryPoint(),
) = RoomFlowNode( ) = RoomLoadedFlowNode(
buildContext = BuildContext.root(savedStateMap = null), buildContext = BuildContext.root(savedStateMap = null),
plugins = plugins, plugins = plugins,
messagesEntryPoint = messagesEntryPoint, messagesEntryPoint = messagesEntryPoint,
@ -91,15 +91,15 @@ class RoomFlowNodeTest {
// GIVEN // GIVEN
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val fakeMessagesEntryPoint = FakeMessagesEntryPoint() val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
val inputs = RoomFlowNode.Inputs(room) val inputs = RoomLoadedFlowNode.Inputs(room)
val roomFlowNode = aRoomFlowNode(listOf(inputs), fakeMessagesEntryPoint) val roomFlowNode = aRoomFlowNode(listOf(inputs), fakeMessagesEntryPoint)
// WHEN // WHEN
val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper() val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
// THEN // THEN
Truth.assertThat(roomFlowNode.backstack.activeElement).isEqualTo(RoomFlowNode.NavTarget.Messages) Truth.assertThat(roomFlowNode.backstack.activeElement).isEqualTo(RoomLoadedFlowNode.NavTarget.Messages)
roomFlowNodeTestHelper.assertChildHasLifecycle(RoomFlowNode.NavTarget.Messages, Lifecycle.State.CREATED) roomFlowNodeTestHelper.assertChildHasLifecycle(RoomLoadedFlowNode.NavTarget.Messages, Lifecycle.State.CREATED)
val messagesNode = roomFlowNode.childNode(RoomFlowNode.NavTarget.Messages)!! val messagesNode = roomFlowNode.childNode(RoomLoadedFlowNode.NavTarget.Messages)!!
Truth.assertThat(messagesNode.id).isEqualTo(fakeMessagesEntryPoint.nodeId) Truth.assertThat(messagesNode.id).isEqualTo(fakeMessagesEntryPoint.nodeId)
} }
@ -109,14 +109,14 @@ class RoomFlowNodeTest {
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val fakeMessagesEntryPoint = FakeMessagesEntryPoint() val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint() val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint()
val inputs = RoomFlowNode.Inputs(room) val inputs = RoomLoadedFlowNode.Inputs(room)
val roomFlowNode = aRoomFlowNode(listOf(inputs), fakeMessagesEntryPoint, fakeRoomDetailsEntryPoint) val roomFlowNode = aRoomFlowNode(listOf(inputs), fakeMessagesEntryPoint, fakeRoomDetailsEntryPoint)
val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper() val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
// WHEN // WHEN
fakeMessagesEntryPoint.callback?.onRoomDetailsClicked() fakeMessagesEntryPoint.callback?.onRoomDetailsClicked()
// THEN // THEN
roomFlowNodeTestHelper.assertChildHasLifecycle(RoomFlowNode.NavTarget.RoomDetails, Lifecycle.State.CREATED) roomFlowNodeTestHelper.assertChildHasLifecycle(RoomLoadedFlowNode.NavTarget.RoomDetails, Lifecycle.State.CREATED)
val roomDetailsNode = roomFlowNode.childNode(RoomFlowNode.NavTarget.RoomDetails)!! val roomDetailsNode = roomFlowNode.childNode(RoomLoadedFlowNode.NavTarget.RoomDetails)!!
Truth.assertThat(roomDetailsNode.id).isEqualTo(fakeRoomDetailsEntryPoint.nodeId) Truth.assertThat(roomDetailsNode.id).isEqualTo(fakeRoomDetailsEntryPoint.nodeId)
} }
} }

Loading…
Cancel
Save