From d71a025f9d482ffd62e84d6f983c97c3aa46ed44 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 6 Jul 2023 10:35:32 +0200 Subject: [PATCH] Room : rename the flows --- .../kotlin/io/element/android/x/MainNode.kt | 4 +- .../android/appnav/LoggedInFlowNode.kt | 14 +- .../android/appnav/room/AwaitRoomNode.kt | 177 ----------------- .../appnav/room/LoadingRoomNodeView.kt | 126 ++++++++++++ ...{AwaitRoomState.kt => LoadingRoomState.kt} | 39 ++-- .../android/appnav/room/RoomFlowNode.kt | 168 ++++++---------- .../android/appnav/room/RoomLoadedFlowNode.kt | 179 ++++++++++++++++++ .../android/appnav/RoomFlowNodeTest.kt | 18 +- 8 files changed, 411 insertions(+), 314 deletions(-) delete mode 100644 appnav/src/main/kotlin/io/element/android/appnav/room/AwaitRoomNode.kt create mode 100644 appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomNodeView.kt rename appnav/src/main/kotlin/io/element/android/appnav/room/{AwaitRoomState.kt => LoadingRoomState.kt} (60%) create mode 100644 appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt diff --git a/app/src/main/kotlin/io/element/android/x/MainNode.kt b/app/src/main/kotlin/io/element/android/x/MainNode.kt index fc4e30e749..8e7d0f194d 100644 --- a/app/src/main/kotlin/io/element/android/x/MainNode.kt +++ b/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.plugin.Plugin 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.libraries.architecture.bindings 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) { val component = bindings().roomComponentBuilder().room(room).build() mainDaggerComponentOwner.addComponent(identifier, component) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 369b3054fb..432b94e744 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -42,8 +42,8 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode 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.RoomLoadedFlowNode import io.element.android.features.analytics.api.AnalyticsEntryPoint import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.features.invitelist.api.InviteListEntryPoint @@ -208,7 +208,7 @@ class LoggedInFlowNode @AssistedInject constructor( @Parcelize data class Room( val roomId: RoomId, - val initialElement: RoomFlowNode.NavTarget = RoomFlowNode.NavTarget.Messages + val initialElement: RoomLoadedFlowNode.NavTarget = RoomLoadedFlowNode.NavTarget.Messages ) : NavTarget @Parcelize @@ -256,7 +256,7 @@ class LoggedInFlowNode @AssistedInject constructor( } 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() { @@ -270,13 +270,13 @@ class LoggedInFlowNode @AssistedInject constructor( } is NavTarget.Room -> { val nodeLifecycleCallbacks = plugins() - val callback = object : RoomFlowNode.Callback { + val callback = object : RoomLoadedFlowNode.Callback { override fun onForwardedToSingleRoom(roomId: RoomId) { coroutineScope.launch { attachRoom(roomId) } } } - val inputs = AwaitRoomNode.Inputs(roomId = navTarget.roomId, initialElement = navTarget.initialElement) - createNode(buildContext, plugins = listOf(inputs, callback) + nodeLifecycleCallbacks) + val inputs = RoomFlowNode.Inputs(roomId = navTarget.roomId, initialElement = navTarget.initialElement) + createNode(buildContext, plugins = listOf(inputs, callback) + nodeLifecycleCallbacks) } NavTarget.Settings -> { 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 { backstack.singleTop(NavTarget.RoomList) backstack.push(NavTarget.Room(roomId)) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/AwaitRoomNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/AwaitRoomNode.kt deleted file mode 100644 index bd894a4aeb..0000000000 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/AwaitRoomNode.kt +++ /dev/null @@ -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, - awaitRoomStateFlowFactory: AwaitRoomStateFlowFactory, -) : - BackstackNode( - 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() - val roomFlowNodeCallback = plugins() - val awaitRoomState = awaitRoomStateFlow.value - if (awaitRoomState is AwaitRoomState.Loaded) { - val inputs = RoomFlowNode.Inputs(awaitRoomState.room, initialElement = inputs.initialElement) - createNode(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, - ) - } -} - diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomNodeView.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomNodeView.kt new file mode 100644 index 0000000000..394ed1223b --- /dev/null +++ b/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 = {} + ) +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/AwaitRoomState.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomState.kt similarity index 60% rename from appnav/src/main/kotlin/io/element/android/appnav/room/AwaitRoomState.kt rename to appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomState.kt index a0f0e1cd33..9e96fadbc4 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/AwaitRoomState.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomState.kt @@ -16,12 +16,14 @@ 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.SingleIn import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.MatrixRoom import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asFlow @@ -29,25 +31,36 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import javax.inject.Inject -sealed interface AwaitRoomState { - object Loading : AwaitRoomState - object Error : AwaitRoomState - data class Loaded(val room: MatrixRoom) : AwaitRoomState +sealed interface LoadingRoomState { + object Loading : LoadingRoomState + object Error : LoadingRoomState + data class Loaded(val room: MatrixRoom) : LoadingRoomState +} + +open class LoadingRoomStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + LoadingRoomState.Loading, + LoadingRoomState.Error + ) } @SingleIn(SessionScope::class) class AwaitRoomStateFlowFactory @Inject constructor(private val matrixClient: MatrixClient) { - fun create(lifecycleScope: CoroutineScope, roomId: RoomId): StateFlow = suspend { + fun create(lifecycleScope: CoroutineScope, roomId: RoomId): StateFlow = + getRoomFlow(roomId) + .map { room -> + if (room != null) { + LoadingRoomState.Loaded(room) + } else { + LoadingRoomState.Error + } + } + .stateIn(lifecycleScope, SharingStarted.Eagerly, LoadingRoomState.Loading) + + private fun getRoomFlow(roomId: RoomId): Flow = suspend { matrixClient.getRoom(roomId = roomId) } .asFlow() - .map { room -> - if (room != null) { - AwaitRoomState.Loaded(room) - } else { - AwaitRoomState.Error - } - } - .stateIn(lifecycleScope, SharingStarted.Eagerly, AwaitRoomState.Loading) } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt index fd4b4e5174..a231664399 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt @@ -14,166 +14,122 @@ * limitations under the License. */ +@file:OptIn(ExperimentalMaterial3Api::class) + package io.element.android.appnav.room import android.os.Parcelable +import androidx.compose.material3.ExperimentalMaterial3Api 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.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.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.appnav.safeRoot 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.createNode 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.distinctUntilChanged import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize -import timber.log.Timber @ContributesNode(SessionScope::class) class RoomFlowNode @AssistedInject constructor( - @Assisted buildContext: BuildContext, + @Assisted val buildContext: BuildContext, @Assisted plugins: List, - private val messagesEntryPoint: MessagesEntryPoint, - private val roomDetailsEntryPoint: RoomDetailsEntryPoint, - private val appNavigationStateService: AppNavigationStateService, - roomMembershipObserver: RoomMembershipObserver, -) : BackstackNode( - 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 - } + awaitRoomStateFlowFactory: AwaitRoomStateFlowFactory, +) : + BackstackNode( + backstack = BackStack( + initialElement = NavTarget.Loading, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins + ) { data class Inputs( - val room: MatrixRoom, - val initialElement: NavTarget = NavTarget.Messages, + val roomId: RoomId, + val initialElement: RoomLoadedFlowNode.NavTarget = RoomLoadedFlowNode.NavTarget.Messages, ) : NodeInputs private val inputs: Inputs = inputs() - private val callbacks = plugins.filterIsInstance() + private val loadingRoomStateStateFlow = awaitRoomStateFlowFactory.create(lifecycleScope, inputs.roomId) - init { - lifecycle.subscribe( - onCreate = { - Timber.v("OnCreate") - plugins().forEach { it.onFlowCreated(id, inputs.room) } - appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId) - fetchRoomMembers() - }, - onDestroy = { - Timber.v("OnDestroy") - plugins().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() + sealed interface NavTarget : Parcelable { + @Parcelize + object Loading : NavTarget + + @Parcelize + object Loaded : NavTarget } - 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 onBuilt() { + super.onBuilt() + loadingRoomStateStateFlow + .map { + it is LoadingRoomState.Loaded } + .distinctUntilChanged() + .onEach { isLoaded -> + if (isLoaded) { + backstack.safeRoot(NavTarget.Loaded) + } else { + backstack.safeRoot(NavTarget.Loading) + } + }.launchIn(lifecycleScope) } 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) } - } + NavTarget.Loaded -> { + val nodeLifecycleCallbacks = plugins() + val roomFlowNodeCallback = plugins() + val awaitRoomState = loadingRoomStateStateFlow.value + if (awaitRoomState is LoadingRoomState.Loaded) { + val inputs = RoomLoadedFlowNode.Inputs(awaitRoomState.room, initialElement = inputs.initialElement) + createNode(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback + nodeLifecycleCallbacks) + } else { + loadingNode(buildContext, this::navigateUp) } - 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()) + NavTarget.Loading -> { + loadingNode(buildContext, this::navigateUp) } } } - sealed interface NavTarget : Parcelable { - @Parcelize - object Messages : NavTarget - - @Parcelize - object RoomDetails : NavTarget - - @Parcelize - data class RoomMemberDetails(val userId: UserId) : NavTarget + private fun loadingNode(buildContext: BuildContext, onBackClicked: () -> Unit) = node(buildContext) { modifier -> + val loadingRoomState by loadingRoomStateStateFlow.collectAsState() + LoadingRoomNodeView( + state = loadingRoomState, + modifier = modifier, + onBackClicked = onBackClicked + ) } @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(), ) } } + diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt new file mode 100644 index 0000000000..73a8579b07 --- /dev/null +++ b/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, + private val messagesEntryPoint: MessagesEntryPoint, + private val roomDetailsEntryPoint: RoomDetailsEntryPoint, + private val appNavigationStateService: AppNavigationStateService, + roomMembershipObserver: RoomMembershipObserver, +) : BackstackNode( + 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() + + init { + lifecycle.subscribe( + onCreate = { + Timber.v("OnCreate") + plugins().forEach { it.onFlowCreated(id, inputs.room) } + appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId) + fetchRoomMembers() + }, + onDestroy = { + Timber.v("OnDestroy") + plugins().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() + } + + 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(), + ) + } +} diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt index 6f816f9de9..0f5dcd84f6 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt +++ b/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.unit.common.helper.parentNodeTestHelper 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.roomdetails.api.RoomDetailsEntryPoint import io.element.android.libraries.architecture.childNode @@ -77,7 +77,7 @@ class RoomFlowNodeTest { plugins: List, messagesEntryPoint: MessagesEntryPoint = FakeMessagesEntryPoint(), roomDetailsEntryPoint: RoomDetailsEntryPoint = FakeRoomDetailsEntryPoint(), - ) = RoomFlowNode( + ) = RoomLoadedFlowNode( buildContext = BuildContext.root(savedStateMap = null), plugins = plugins, messagesEntryPoint = messagesEntryPoint, @@ -91,15 +91,15 @@ class RoomFlowNodeTest { // GIVEN val room = FakeMatrixRoom() val fakeMessagesEntryPoint = FakeMessagesEntryPoint() - val inputs = RoomFlowNode.Inputs(room) + val inputs = RoomLoadedFlowNode.Inputs(room) val roomFlowNode = aRoomFlowNode(listOf(inputs), fakeMessagesEntryPoint) // WHEN val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper() // THEN - Truth.assertThat(roomFlowNode.backstack.activeElement).isEqualTo(RoomFlowNode.NavTarget.Messages) - roomFlowNodeTestHelper.assertChildHasLifecycle(RoomFlowNode.NavTarget.Messages, Lifecycle.State.CREATED) - val messagesNode = roomFlowNode.childNode(RoomFlowNode.NavTarget.Messages)!! + Truth.assertThat(roomFlowNode.backstack.activeElement).isEqualTo(RoomLoadedFlowNode.NavTarget.Messages) + roomFlowNodeTestHelper.assertChildHasLifecycle(RoomLoadedFlowNode.NavTarget.Messages, Lifecycle.State.CREATED) + val messagesNode = roomFlowNode.childNode(RoomLoadedFlowNode.NavTarget.Messages)!! Truth.assertThat(messagesNode.id).isEqualTo(fakeMessagesEntryPoint.nodeId) } @@ -109,14 +109,14 @@ class RoomFlowNodeTest { val room = FakeMatrixRoom() val fakeMessagesEntryPoint = FakeMessagesEntryPoint() val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint() - val inputs = RoomFlowNode.Inputs(room) + val inputs = RoomLoadedFlowNode.Inputs(room) val roomFlowNode = aRoomFlowNode(listOf(inputs), fakeMessagesEntryPoint, fakeRoomDetailsEntryPoint) val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper() // WHEN fakeMessagesEntryPoint.callback?.onRoomDetailsClicked() // THEN - roomFlowNodeTestHelper.assertChildHasLifecycle(RoomFlowNode.NavTarget.RoomDetails, Lifecycle.State.CREATED) - val roomDetailsNode = roomFlowNode.childNode(RoomFlowNode.NavTarget.RoomDetails)!! + roomFlowNodeTestHelper.assertChildHasLifecycle(RoomLoadedFlowNode.NavTarget.RoomDetails, Lifecycle.State.CREATED) + val roomDetailsNode = roomFlowNode.childNode(RoomLoadedFlowNode.NavTarget.RoomDetails)!! Truth.assertThat(roomDetailsNode.id).isEqualTo(fakeRoomDetailsEntryPoint.nodeId) } }