Nav: First iteration integrating Appyx

This commit is contained in:
ganfra 2022-12-21 17:56:01 +01:00
parent 4c88d8e3c2
commit 2de26a30d5
28 changed files with 566 additions and 280 deletions

View file

@ -6,177 +6,48 @@
package io.element.android.x
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.navigation.NavHostController
import com.airbnb.android.showkase.models.Showkase
import com.airbnb.mvrx.compose.mavericksActivityViewModel
import com.airbnb.mvrx.compose.mavericksViewModel
import com.bumble.appyx.core.integration.NodeHost
import com.bumble.appyx.core.integrationpoint.NodeComponentActivity
import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi
import com.ramcosta.composedestinations.DestinationsNavHost
import com.ramcosta.composedestinations.animations.defaults.RootNavGraphDefaultAnimations
import com.ramcosta.composedestinations.animations.rememberAnimatedNavHostEngine
import com.ramcosta.composedestinations.manualcomposablecalls.animatedComposable
import com.ramcosta.composedestinations.navigation.dependency
import com.ramcosta.composedestinations.spec.Route
import io.element.android.x.core.compose.OnLifecycleEvent
import io.element.android.x.core.di.DaggerComponentOwner
import io.element.android.x.core.di.bindings
import io.element.android.x.designsystem.ElementXTheme
import io.element.android.x.destinations.OnBoardingScreenNavigationDestination
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import io.element.android.x.di.AppBindings
import io.element.android.x.node.RootFlowNode
private const val transitionAnimationDuration = 500
class MainActivity : NodeComponentActivity(), DaggerComponentOwner {
class MainActivity : ComponentActivity() {
override val daggerComponent: Any
get() = listOfNotNull((applicationContext as? DaggerComponentOwner)?.daggerComponent)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val appBindings = bindings<AppBindings>()
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
ElementXTheme {
MainScreen(viewModel = mavericksActivityViewModel())
}
}
}
@Composable
private fun ShowkaseButton(
isVisible: Boolean,
onClick: () -> Unit,
onCloseClicked: () -> Unit
) {
if (isVisible) {
Button(
modifier = Modifier
.padding(top = 32.dp, start = 16.dp),
onClick = onClick
) {
Text(text = "Showkase Browser")
IconButton(
modifier = Modifier
.padding(start = 8.dp)
.size(16.dp),
onClick = onCloseClicked,
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Icon(imageVector = Icons.Filled.Close, contentDescription = "")
NodeHost(integrationPoint = appyxIntegrationPoint) {
RootFlowNode(
buildContext = it,
daggerComponentOwner = this,
matrix = appBindings.matrix(),
sessionComponentsOwner = appBindings.sessionComponentsOwner()
)
}
}
}
}
}
@Composable
private fun MainScreen(viewModel: MainViewModel) {
val startRoute = runBlocking {
if (!viewModel.isLoggedIn()) {
OnBoardingScreenNavigationDestination
} else {
viewModel.restoreSession()
NavGraphs.root.startRoute
}
}
var isShowkaseButtonVisible by remember { mutableStateOf(BuildConfig.DEBUG) }
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
MainContent(
startRoute = startRoute
)
ShowkaseButton(
isVisible = isShowkaseButtonVisible,
onCloseClicked = { isShowkaseButtonVisible = false },
onClick = { startActivity(Showkase.getBrowserIntent(this@MainActivity)) }
)
}
OnLifecycleEvent { _, event ->
Timber.v("OnLifecycleEvent: $event")
}
}
@Composable
private fun MainContent(startRoute: Route) {
val engine = rememberAnimatedNavHostEngine(
rootDefaultAnimations = RootNavGraphDefaultAnimations(
enterTransition = {
slideIntoContainer(
AnimatedContentScope.SlideDirection.Left,
animationSpec = tween(transitionAnimationDuration)
)
},
exitTransition = {
slideOutOfContainer(
AnimatedContentScope.SlideDirection.Left,
animationSpec = tween(transitionAnimationDuration)
)
},
popEnterTransition = {
slideIntoContainer(
AnimatedContentScope.SlideDirection.Right,
animationSpec = tween(transitionAnimationDuration)
)
},
popExitTransition = {
slideOutOfContainer(
AnimatedContentScope.SlideDirection.Right,
animationSpec = tween(transitionAnimationDuration)
)
}
)
)
val navController = engine.rememberNavController()
LogNavigation(navController)
DestinationsNavHost(
modifier = Modifier.background(MaterialTheme.colorScheme.background),
engine = engine,
navController = navController,
navGraph = NavGraphs.root,
startRoute = startRoute,
dependenciesContainerBuilder = {
}
)
}
@Composable
private fun LogNavigation(navController: NavHostController) {
LaunchedEffect(key1 = navController) {
navController.appCurrentDestinationFlow.collect {
Timber.d("Navigating to ${it.route}")
}
}
}
@Composable
@Preview
fun MainContentPreview() {
MainContent(startRoute = OnBoardingScreenNavigationDestination)
}
}

View file

@ -1,90 +0,0 @@
package io.element.android.x
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.navigation.popUpTo
import io.element.android.x.core.di.bindings
import io.element.android.x.destinations.ChangeServerScreenNavigationDestination
import io.element.android.x.destinations.LoginScreenNavigationDestination
import io.element.android.x.destinations.MessagesScreenNavigationDestination
import io.element.android.x.destinations.OnBoardingScreenNavigationDestination
import io.element.android.x.destinations.RoomListScreenNavigationDestination
import io.element.android.x.di.AppBindings
import io.element.android.x.features.login.LoginScreen
import io.element.android.x.features.login.changeserver.ChangeServerScreen
import io.element.android.x.features.messages.MessagesScreen
import io.element.android.x.features.onboarding.OnBoardingScreen
import io.element.android.x.features.roomlist.RoomListScreen
import io.element.android.x.matrix.core.RoomId
@Destination
@Composable
fun OnBoardingScreenNavigation(navigator: DestinationsNavigator) {
OnBoardingScreen(
onSignUp = {
// TODO
},
onSignIn = {
navigator.navigate(LoginScreenNavigationDestination)
}
)
}
@Destination
@Composable
fun LoginScreenNavigation(navigator: DestinationsNavigator) {
val sessionComponentsOwner = LocalContext.current.bindings<AppBindings>().sessionComponentsOwner()
LoginScreen(
onChangeServer = {
navigator.navigate(ChangeServerScreenNavigationDestination)
},
onLoginWithSuccess = {
sessionComponentsOwner.create(it)
navigator.navigate(RoomListScreenNavigationDestination) {
popUpTo(OnBoardingScreenNavigationDestination) {
inclusive = true
}
}
}
)
}
// TODO Create a subgraph in Login module
@Destination
@Composable
fun ChangeServerScreenNavigation(navigator: DestinationsNavigator) {
ChangeServerScreen(
onChangeServerSuccess = {
navigator.popBackStack()
}
)
}
@RootNavGraph(start = true)
@Destination
@Composable
fun RoomListScreenNavigation(navigator: DestinationsNavigator) {
val sessionComponentsOwner = LocalContext.current.bindings<AppBindings>().sessionComponentsOwner()
RoomListScreen(
onRoomClicked = { roomId: RoomId ->
navigator.navigate(MessagesScreenNavigationDestination(roomId = roomId.value))
},
onSuccessLogout = {
sessionComponentsOwner.releaseActiveSession()
navigator.navigate(OnBoardingScreenNavigationDestination) {
popUpTo(RoomListScreenNavigationDestination) {
inclusive = true
}
}
}
)
}
@Destination
@Composable
fun MessagesScreenNavigation(roomId: String, navigator: DestinationsNavigator) {
MessagesScreen(roomId = roomId, onBackPressed = navigator::navigateUp)
}

View file

@ -0,0 +1,39 @@
package io.element.android.x.component
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
internal fun ShowkaseButton(
modifier: Modifier = Modifier,
isVisible: Boolean,
onClick: () -> Unit,
onCloseClicked: () -> Unit
) {
if (isVisible) {
Button(
modifier = Modifier
.padding(top = 32.dp, start = 16.dp),
onClick = onClick
) {
Text(text = "Showkase Browser")
IconButton(
modifier = Modifier
.padding(start = 8.dp)
.size(16.dp),
onClick = onCloseClicked,
) {
Icon(imageVector = Icons.Filled.Close, contentDescription = "")
}
}
}
}

View file

@ -2,6 +2,8 @@ package io.element.android.x.di
import com.squareup.anvil.annotations.ContributesTo
import io.element.android.x.matrix.Matrix
import io.element.android.x.node.LoggedInFlowNode
import io.element.android.x.node.RootFlowNode
import kotlinx.coroutines.CoroutineScope
@ContributesTo(AppScope::class)
@ -9,4 +11,4 @@ interface AppBindings {
fun coroutineScope(): CoroutineScope
fun matrix(): Matrix
fun sessionComponentsOwner(): SessionComponentsOwner
}
}

View file

@ -3,17 +3,18 @@ package io.element.android.x.di
import android.content.Context
import io.element.android.x.core.di.bindings
import io.element.android.x.matrix.MatrixClient
import io.element.android.x.matrix.core.SessionId
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
@SingleIn(AppScope::class)
class SessionComponentsOwner @Inject constructor(@ApplicationContext private val context: Context) {
private val sessionComponents = ConcurrentHashMap<String, SessionComponent>()
private val sessionComponents = ConcurrentHashMap<SessionId, SessionComponent>()
var activeSessionComponent: SessionComponent? = null
private set
fun setActive(sessionId: String) {
fun setActive(sessionId: SessionId) {
val sessionComponent = sessionComponents[sessionId]
if (activeSessionComponent != sessionComponent) {
activeSessionComponent = sessionComponent
@ -35,7 +36,7 @@ class SessionComponentsOwner @Inject constructor(@ApplicationContext private val
}
}
fun release(sessionId: String) {
fun release(sessionId: SessionId) {
val sessionComponent = sessionComponents.remove(sessionId)
if (activeSessionComponent == sessionComponent) {
activeSessionComponent = null

View file

@ -17,7 +17,7 @@ class MatrixInitializer : Initializer<Unit> {
}
}
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
override fun dependencies(): List<Class<out Initializer<*>>> = listOf(TimberInitializer::class.java)
}
}

View file

@ -10,6 +10,5 @@ class TimberInitializer : Initializer<Unit> {
Timber.plant(Timber.DebugTree())
}
override fun dependencies(): List<Class<out Initializer<*>>> =
listOf(TimberInitializer::class.java)
}
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}

View file

@ -0,0 +1,60 @@
package io.element.android.x.node
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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.ParentNode
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import io.element.android.x.core.di.viewModelSupportNode
import io.element.android.x.features.messages.MessagesScreen
import io.element.android.x.features.roomlist.RoomListScreen
import io.element.android.x.matrix.core.RoomId
import io.element.android.x.matrix.core.SessionId
import kotlinx.parcelize.Parcelize
class LoggedInFlowNode(
buildContext: BuildContext,
val sessionId: SessionId,
private val backstack: BackStack<NavTarget> = BackStack(
initialElement = NavTarget.RoomList,
savedStateMap = buildContext.savedStateMap,
),
) : ParentNode<LoggedInFlowNode.NavTarget>(
navModel = backstack,
buildContext = buildContext
) {
sealed interface NavTarget : Parcelable {
@Parcelize
object RoomList : NavTarget
@Parcelize
data class Messages(val roomId: RoomId) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.RoomList -> viewModelSupportNode(buildContext) {
RoomListScreen(
onRoomClicked = { backstack.push(NavTarget.Messages(it)) }
)
}
is NavTarget.Messages -> viewModelSupportNode(buildContext) {
MessagesScreen(
roomId = navTarget.roomId.value,
onBackPressed = { backstack.pop() }
)
}
}
}
@Composable
override fun View(modifier: Modifier) {
Children(navModel = backstack)
}
}

View file

@ -0,0 +1,51 @@
package io.element.android.x.node
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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.ParentNode
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.replace
import io.element.android.x.core.di.viewModelSupportNode
import io.element.android.x.features.login.node.LoginFlowNode
import io.element.android.x.features.onboarding.OnBoardingScreen
import kotlinx.parcelize.Parcelize
class NotLoggedInFlowNode(
buildContext: BuildContext,
private val backstack: BackStack<NavTarget> = BackStack(
initialElement = NavTarget.OnBoarding,
savedStateMap = buildContext.savedStateMap,
),
) : ParentNode<NotLoggedInFlowNode.NavTarget>(
navModel = backstack,
buildContext = buildContext
) {
sealed interface NavTarget : Parcelable {
@Parcelize
object OnBoarding : NavTarget
@Parcelize
object LoginFlow : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.OnBoarding -> viewModelSupportNode(buildContext) {
OnBoardingScreen(
onSignIn = { backstack.replace(NavTarget.LoginFlow) }
)
}
NavTarget.LoginFlow -> LoginFlowNode(buildContext)
}
}
@Composable
override fun View(modifier: Modifier) {
Children(navModel = backstack)
}
}

View file

@ -0,0 +1,136 @@
package io.element.android.x.node
import android.os.Parcelable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat.startActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import com.airbnb.android.showkase.models.Showkase
import com.bumble.appyx.core.children.whenChildAttached
import com.bumble.appyx.core.clienthelper.interactor.Interactor
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.ParentNode
import com.bumble.appyx.core.node.node
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.replace
import io.element.android.x.BuildConfig
import io.element.android.x.component.ShowkaseButton
import io.element.android.x.core.di.DaggerComponentOwner
import io.element.android.x.di.SessionComponentsOwner
import io.element.android.x.getBrowserIntent
import io.element.android.x.matrix.Matrix
import io.element.android.x.matrix.core.SessionId
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize
import timber.log.Timber
class SessionComponentsOwnerInteractor(private val sessionComponentsOwner: SessionComponentsOwner) : Interactor<RootFlowNode>() {
override fun onCreate(lifecycle: Lifecycle) {
lifecycle.subscribe(onCreate = {
whenChildAttached { commonLifecycle: Lifecycle, child: LoggedInFlowNode ->
Timber.v("LoggedInFlowNode attached: ${child.sessionId} ")
commonLifecycle.subscribe(
onDestroy = {
Timber.v("LoggedInFlowNode destroyed: ${child.sessionId}")
sessionComponentsOwner.release(child.sessionId)
}
)
}
})
}
}
class RootFlowNode(
buildContext: BuildContext,
private val backstack: BackStack<NavTarget> = BackStack(
initialElement = NavTarget.SplashScreen,
savedStateMap = buildContext.savedStateMap,
),
private val daggerComponentOwner: DaggerComponentOwner,
private val matrix: Matrix,
private val sessionComponentsOwner: SessionComponentsOwner,
) :
ParentNode<RootFlowNode.NavTarget>(
navModel = backstack,
buildContext = buildContext,
plugins = listOf(SessionComponentsOwnerInteractor(sessionComponentsOwner)),
),
DaggerComponentOwner by daggerComponentOwner {
init {
matrix.isLoggedIn()
.distinctUntilChanged()
.onEach { isLoggedIn ->
if (isLoggedIn) {
val matrixClient = matrix.restoreSession()
if (matrixClient == null) {
backstack.replace(NavTarget.NotLoggedInFlow)
} else {
matrixClient.startSync()
sessionComponentsOwner.create(matrixClient)
backstack.replace(NavTarget.LoggedInFlow(matrixClient.sessionId))
}
} else {
backstack.replace(NavTarget.NotLoggedInFlow)
}
}
.launchIn(lifecycleScope)
}
@Composable
override fun View(modifier: Modifier) {
var isShowkaseButtonVisible by remember { mutableStateOf(BuildConfig.DEBUG) }
Box(
modifier = modifier
.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
) {
Children(navModel = backstack)
val context = LocalContext.current
ShowkaseButton(
isVisible = isShowkaseButtonVisible,
onCloseClicked = { isShowkaseButtonVisible = false },
onClick = { startActivity(context, Showkase.getBrowserIntent(context), null) }
)
}
}
sealed interface NavTarget : Parcelable {
@Parcelize
object SplashScreen : NavTarget
@Parcelize
object NotLoggedInFlow : NavTarget
@Parcelize
data class LoggedInFlow(val sessionId: SessionId) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.LoggedInFlow -> LoggedInFlowNode(buildContext, navTarget.sessionId)
NavTarget.NotLoggedInFlow -> NotLoggedInFlowNode(buildContext)
NavTarget.SplashScreen -> node(buildContext) {
Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
}
}