Migrate Login to new architecture and make some adjustments

This commit is contained in:
ganfra
2023-01-06 15:15:45 +01:00
parent 4fb063654f
commit 6a5bcf7058
25 changed files with 385 additions and 304 deletions
@@ -4,11 +4,12 @@ import android.content.Context
import com.squareup.anvil.annotations.MergeComponent import com.squareup.anvil.annotations.MergeComponent
import dagger.BindsInstance import dagger.BindsInstance
import dagger.Component import dagger.Component
import io.element.android.x.architecture.NodeFactoriesBindings
import io.element.android.x.architecture.viewmodel.DaggerMavericksBindings import io.element.android.x.architecture.viewmodel.DaggerMavericksBindings
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
@MergeComponent(AppScope::class) @MergeComponent(AppScope::class)
interface AppComponent : DaggerMavericksBindings { interface AppComponent : DaggerMavericksBindings, NodeFactoriesBindings {
@Component.Factory @Component.Factory
interface Factory { interface Factory {
@@ -8,10 +8,10 @@ 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.ParentNode 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.BackStack
import com.bumble.appyx.navmodel.backstack.operation.replace import com.bumble.appyx.navmodel.backstack.operation.replace
import io.element.android.x.architecture.viewmodel.viewModelSupportNode import io.element.android.x.features.login.LoginFlowNode
import io.element.android.x.features.login.node.LoginFlowNode
import io.element.android.x.features.onboarding.OnBoardingScreen import io.element.android.x.features.onboarding.OnBoardingScreen
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
@@ -44,7 +44,7 @@ class NotLoggedInFlowNode(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.OnBoarding -> viewModelSupportNode(buildContext) { NavTarget.OnBoarding -> node(buildContext) {
OnBoardingScreen( OnBoardingScreen(
onSignIn = { backstack.replace(NavTarget.LoginFlow) } onSignIn = { backstack.replace(NavTarget.LoginFlow) }
) )
@@ -1,4 +1,4 @@
package io.element.android.x.features.login.node package io.element.android.x.features.login
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -8,11 +8,10 @@ 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.ParentNode import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import io.element.android.x.architecture.viewmodel.viewModelSupportNode import io.element.android.x.architecture.createNode
import io.element.android.x.features.login.LoginScreen import io.element.android.x.features.login.changeserver.ChangeServerNode
import io.element.android.x.features.login.changeserver.ChangeServerScreen import io.element.android.x.features.login.root.LoginRootNode
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
class LoginFlowNode( class LoginFlowNode(
@@ -26,6 +25,12 @@ class LoginFlowNode(
buildContext = buildContext buildContext = buildContext
) { ) {
private val loginRootCallback = object : LoginRootNode.Callback {
override fun onChangeHomeServer() {
backstack.push(NavTarget.ChangeServer)
}
}
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
object Root : NavTarget object Root : NavTarget
@@ -36,16 +41,8 @@ class LoginFlowNode(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Root -> viewModelSupportNode(buildContext) { NavTarget.Root -> createNode<LoginRootNode>(buildContext, plugins = listOf(loginRootCallback))
LoginScreen( NavTarget.ChangeServer -> createNode<ChangeServerNode>(buildContext)
onChangeServer = { backstack.push(NavTarget.ChangeServer) }
)
}
NavTarget.ChangeServer -> viewModelSupportNode(buildContext) {
ChangeServerScreen(
onChangeServerSuccess = { backstack.pop() }
)
}
} }
} }
@@ -53,5 +50,4 @@ class LoginFlowNode(
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
Children(navModel = backstack) Children(navModel = backstack)
} }
} }
@@ -1,67 +0,0 @@
package io.element.android.x.features.login
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshotFlow
import com.airbnb.mvrx.MavericksViewModel
import com.airbnb.mvrx.MavericksViewModelFactory
import com.airbnb.mvrx.Uninitialized
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.x.anvilannotations.ContributesViewModel
import io.element.android.x.architecture.viewmodel.daggerMavericksViewModelFactory
import io.element.android.x.di.AppScope
import io.element.android.x.matrix.Matrix
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@ContributesViewModel(AppScope::class)
class LoginViewModel @AssistedInject constructor(
private val matrix: Matrix,
@Assisted initialState: LoginViewState) :
MavericksViewModel<LoginViewState>(initialState) {
companion object : MavericksViewModelFactory<LoginViewModel, LoginViewState> by daggerMavericksViewModelFactory()
var formState = mutableStateOf(LoginFormState.Default)
private set
init {
snapshotFlow { formState.value }
.onEach {
setState { copy(formState = it) }
}.launchIn(viewModelScope)
}
fun onResume() {
val currentHomeserver = matrix.getHomeserverOrDefault()
setState {
copy(
homeserver = currentHomeserver
)
}
}
fun onSubmit() {
viewModelScope.launch {
suspend {
val state = awaitState()
// Ensure the server is provided to the Rust SDK
matrix.setHomeserver(state.homeserver)
matrix.login(state.formState.login.trim(), state.formState.password.trim())
}.execute {
copy(loggedInSessionId = it)
}
}
}
fun onSetPassword(password: String) {
formState.value = formState.value.copy(password = password)
setState { copy(loggedInSessionId = Uninitialized) }
}
fun onSetName(name: String) {
formState.value = formState.value.copy(login = name)
setState { copy(loggedInSessionId = Uninitialized) }
}
}
@@ -1,26 +0,0 @@
package io.element.android.x.features.login
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.Uninitialized
import io.element.android.x.matrix.core.SessionId
data class LoginViewState(
val homeserver: String = "",
val loggedInSessionId: Async<SessionId> = Uninitialized,
val formState: LoginFormState = LoginFormState.Default,
) : MavericksState {
val submitEnabled =
formState.login.isNotEmpty() && formState.password.isNotEmpty() && loggedInSessionId !is Loading
}
data class LoginFormState(
val login: String,
val password: String
) {
companion object {
val Default = LoginFormState("", "")
}
}
@@ -0,0 +1,6 @@
package io.element.android.x.features.login.changeserver
sealed interface ChangeServerEvents {
data class SetServer(val server: String) : ChangeServerEvents
object Submit: ChangeServerEvents
}
@@ -0,0 +1,47 @@
package io.element.android.x.features.login.changeserver
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.x.anvilannotations.ContributesNode
import io.element.android.x.architecture.presenterConnector
import io.element.android.x.di.AppScope
@ContributesNode(AppScope::class)
class ChangeServerNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: ChangeServerPresenter,
) : Node(buildContext, plugins = plugins) {
private val presenterConnector = presenterConnector(presenter)
private fun onChangeServer(server: String) {
presenterConnector.emitEvent(ChangeServerEvents.SetServer(server))
}
private fun onSubmit() {
presenterConnector.emitEvent(ChangeServerEvents.Submit)
}
private fun onSuccess() {
navigateUp()
}
@Composable
override fun View(modifier: Modifier) {
val state by presenterConnector.stateFlow.collectAsState()
ChangeServerView(
state = state,
onChangeServer = this::onChangeServer,
onChangeServerSubmit = this::onSubmit,
onChangeServerSuccess = this::onSuccess,
)
}
}
@@ -0,0 +1,47 @@
package io.element.android.x.features.login.changeserver
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.x.architecture.Async
import io.element.android.x.architecture.Presenter
import io.element.android.x.architecture.execute
import io.element.android.x.matrix.Matrix
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import javax.inject.Inject
class ChangeServerPresenter @Inject constructor(private val matrix: Matrix) : Presenter<ChangeServerState, ChangeServerEvents> {
@Composable
override fun present(events: Flow<ChangeServerEvents>): ChangeServerState {
val homeserver = rememberSaveable {
mutableStateOf(matrix.getHomeserverOrDefault())
}
val changeServerAction: MutableState<Async<Unit>> = remember {
mutableStateOf(Async.Uninitialized)
}
LaunchedEffect(Unit) {
events.collect { event ->
when (event) {
is ChangeServerEvents.SetServer -> homeserver.value = event.server
ChangeServerEvents.Submit -> submit(homeserver.value, changeServerAction)
}
}
}
return ChangeServerState(
homeserver = homeserver.value,
changeServerAction = changeServerAction.value
)
}
private fun CoroutineScope.submit(homeserver: String, changeServerAction: MutableState<Async<Unit>>) = launch {
suspend {
matrix.setHomeserver(homeserver)
}.execute(changeServerAction)
}
}
@@ -0,0 +1,10 @@
package io.element.android.x.features.login.changeserver
import io.element.android.x.architecture.Async
data class ChangeServerState(
val homeserver: String = "",
val changeServerAction: Async<Unit> = Async.Uninitialized,
) {
val submitEnabled = homeserver.isNotEmpty() && changeServerAction !is Async.Loading
}
@@ -26,6 +26,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -35,33 +36,17 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.airbnb.mvrx.Fail import io.element.android.x.architecture.Async
import com.airbnb.mvrx.Loading import io.element.android.x.core.compose.textFieldState
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.compose.collectAsState
import com.airbnb.mvrx.compose.mavericksViewModel
import io.element.android.x.designsystem.ElementXTheme import io.element.android.x.designsystem.ElementXTheme
import io.element.android.x.designsystem.components.VectorIcon import io.element.android.x.designsystem.components.VectorIcon
import io.element.android.x.features.login.R import io.element.android.x.features.login.R
import io.element.android.x.features.login.error.changeServerError import io.element.android.x.features.login.error.changeServerError
@Composable
fun ChangeServerScreen(
viewModel: ChangeServerViewModel = mavericksViewModel(),
onChangeServerSuccess: () -> Unit = { }
) {
val state: ChangeServerViewState by viewModel.collectAsState()
ChangeServerContent(
state = state,
onChangeServer = viewModel::setServer,
onChangeServerSubmit = viewModel::setServerSubmit,
onChangeServerSuccess = onChangeServerSuccess
)
}
@Composable @Composable
fun ChangeServerContent( fun ChangeServerView(
state: ChangeServerViewState, state: ChangeServerState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onChangeServer: (String) -> Unit = {}, onChangeServer: (String) -> Unit = {},
onChangeServerSubmit: () -> Unit = {}, onChangeServerSubmit: () -> Unit = {},
@@ -85,7 +70,7 @@ fun ChangeServerContent(
) )
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
val isError = state.changeServerAction is Fail val isError = state.changeServerAction is Async.Failure
Box( Box(
modifier = Modifier modifier = Modifier
.padding(top = 99.dp) .padding(top = 99.dp)
@@ -126,12 +111,16 @@ fun ChangeServerContent(
fontSize = 16.sp, fontSize = 16.sp,
color = MaterialTheme.colorScheme.secondary color = MaterialTheme.colorScheme.secondary
) )
var homeserverFieldState by textFieldState(stateValue = state.homeserver)
OutlinedTextField( OutlinedTextField(
value = state.homeserver, value = homeserverFieldState,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 200.dp), .padding(top = 200.dp),
onValueChange = onChangeServer, onValueChange = {
homeserverFieldState = it
onChangeServer(it)
},
label = { label = {
Text(text = "Server") Text(text = "Server")
}, },
@@ -144,7 +133,7 @@ fun ChangeServerContent(
onDone = { onChangeServerSubmit() } onDone = { onChangeServerSubmit() }
) )
) )
if (state.changeServerAction is Fail) { if (state.changeServerAction is Async.Failure) {
Text( Text(
text = changeServerError( text = changeServerError(
state.homeserver, state.homeserver,
@@ -164,11 +153,11 @@ fun ChangeServerContent(
) { ) {
Text(text = "Continue") Text(text = "Continue")
} }
if (state.changeServerAction is Success) { if (state.changeServerAction is Async.Success) {
onChangeServerSuccess() onChangeServerSuccess()
} }
} }
if (state.changeServerAction is Loading) { if (state.changeServerAction is Async.Loading) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center) modifier = Modifier.align(Alignment.Center)
) )
@@ -181,8 +170,8 @@ fun ChangeServerContent(
@Preview @Preview
fun ChangeServerContentPreview() { fun ChangeServerContentPreview() {
ElementXTheme { ElementXTheme {
ChangeServerContent( ChangeServerView(
state = ChangeServerViewState(homeserver = "matrix.org"), state = ChangeServerState(homeserver = "matrix.org"),
) )
} }
} }
@@ -1,51 +0,0 @@
package io.element.android.x.features.login.changeserver
import com.airbnb.mvrx.MavericksViewModel
import com.airbnb.mvrx.MavericksViewModelFactory
import com.airbnb.mvrx.Uninitialized
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.x.anvilannotations.ContributesViewModel
import io.element.android.x.architecture.viewmodel.daggerMavericksViewModelFactory
import io.element.android.x.di.AppScope
import io.element.android.x.matrix.Matrix
import kotlinx.coroutines.launch
@ContributesViewModel(AppScope::class)
class ChangeServerViewModel @AssistedInject constructor(
private val matrix: Matrix,
@Assisted initialState: ChangeServerViewState
) :
MavericksViewModel<ChangeServerViewState>(initialState) {
companion object :
MavericksViewModelFactory<ChangeServerViewModel, ChangeServerViewState> by daggerMavericksViewModelFactory()
init {
setState {
copy(
homeserver = matrix.getHomeserverOrDefault()
)
}
}
fun setServer(server: String) {
setState {
copy(
homeserver = server,
changeServerAction = Uninitialized,
)
}
}
fun setServerSubmit() {
viewModelScope.launch {
suspend {
val state = awaitState()
matrix.setHomeserver(state.homeserver)
}.execute {
copy(changeServerAction = it)
}
}
}
}
@@ -1,13 +0,0 @@
package io.element.android.x.features.login.changeserver
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.Uninitialized
data class ChangeServerViewState(
val homeserver: String = "",
val changeServerAction: Async<Unit> = Uninitialized,
) : MavericksState {
val submitEnabled = homeserver.isNotEmpty() && changeServerAction !is Loading
}
@@ -3,8 +3,8 @@ package io.element.android.x.features.login.error
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import io.element.android.x.core.uri.isValidUrl import io.element.android.x.core.uri.isValidUrl
import io.element.android.x.features.login.root.LoginFormState
import io.element.android.x.element.resources.R as ElementR import io.element.android.x.element.resources.R as ElementR
import io.element.android.x.features.login.LoginFormState
@Composable @Composable
fun loginError( fun loginError(
@@ -0,0 +1,8 @@
package io.element.android.x.features.login.root
sealed interface LoginRootEvents {
object RefreshHomeServer : LoginRootEvents
data class SetLogin(val login: String) : LoginRootEvents
data class SetPassword(val password: String) : LoginRootEvents
object Submit : LoginRootEvents
}
@@ -0,0 +1,64 @@
package io.element.android.x.features.login.root
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
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 dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.x.anvilannotations.ContributesNode
import io.element.android.x.architecture.presenterConnector
import io.element.android.x.di.AppScope
@ContributesNode(AppScope::class)
class LoginRootNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: LoginRootPresenter,
) : Node(buildContext, plugins = plugins) {
private val presenterConnector = presenterConnector(presenter)
init {
lifecycle.subscribe(
onResume = { presenterConnector.emitEvent(LoginRootEvents.RefreshHomeServer) }
)
}
interface Callback : Plugin {
fun onChangeHomeServer()
}
private fun onChangeHomeServer() {
plugins<Callback>().forEach { it.onChangeHomeServer() }
}
private fun onLoginChanged(login: String) {
presenterConnector.emitEvent(LoginRootEvents.SetLogin(login))
}
private fun onPasswordChanged(password: String) {
presenterConnector.emitEvent(LoginRootEvents.SetPassword(password))
}
private fun onSubmit() {
presenterConnector.emitEvent(LoginRootEvents.Submit)
}
@Composable
override fun View(modifier: Modifier) {
val state by presenterConnector.stateFlow.collectAsState()
LoginRootScreen(
state = state,
onChangeServer = this::onChangeHomeServer,
onLoginChanged = this::onLoginChanged,
onPasswordChanged = this::onPasswordChanged,
onSubmitClicked = this::onSubmit
)
}
}
@@ -0,0 +1,69 @@
package io.element.android.x.features.login.root
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.x.architecture.Presenter
import io.element.android.x.matrix.Matrix
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import javax.inject.Inject
class LoginRootPresenter @Inject constructor(private val matrix: Matrix) : Presenter<LoginRootState, LoginRootEvents> {
@Composable
override fun present(events: Flow<LoginRootEvents>): LoginRootState {
val homeserver = rememberSaveable {
mutableStateOf(matrix.getHomeserverOrDefault())
}
val loggedInState: MutableState<LoggedInState> = remember {
mutableStateOf(LoggedInState.NotLoggedIn)
}
val formState = rememberSaveable {
mutableStateOf(LoginFormState.Default)
}
LaunchedEffect(Unit) {
events.collect { event ->
when (event) {
LoginRootEvents.RefreshHomeServer -> refreshHomeServer(homeserver)
is LoginRootEvents.SetLogin -> updateFormState(formState) {
copy(login = event.login)
}
is LoginRootEvents.SetPassword -> updateFormState(formState) {
copy(password = event.password)
}
LoginRootEvents.Submit -> submit(homeserver.value, formState.value, loggedInState)
}
}
}
return LoginRootState(
homeserver = homeserver.value,
loggedInState = loggedInState.value,
formState = formState.value
)
}
private fun CoroutineScope.submit(homeserver: String, formState: LoginFormState, loggedInState: MutableState<LoggedInState>) = launch {
loggedInState.value = LoggedInState.LoggingIn
try {
matrix.setHomeserver(homeserver)
val sessionId = matrix.login(formState.login.trim(), formState.password.trim())
loggedInState.value = LoggedInState.LoggedIn(sessionId)
} catch (failure: Throwable) {
loggedInState.value = LoggedInState.ErrorLoggingIn(failure)
}
}
private fun updateFormState(formState: MutableState<LoginFormState>, updateLambda: LoginFormState.() -> LoginFormState) {
formState.value = updateLambda(formState.value)
}
private fun refreshHomeServer(homeserver: MutableState<String>) {
homeserver.value = matrix.getHomeserverOrDefault()
}
}
@@ -1,6 +1,4 @@
@file:OptIn(ExperimentalMaterial3Api::class) package io.element.android.x.features.login.root
package io.element.android.x.features.login
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -26,7 +24,6 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -42,44 +39,15 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.airbnb.mvrx.Fail import io.element.android.x.core.compose.textFieldState
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.compose.collectAsState
import com.airbnb.mvrx.compose.mavericksViewModel
import io.element.android.x.designsystem.ElementXTheme import io.element.android.x.designsystem.ElementXTheme
import io.element.android.x.features.login.error.loginError import io.element.android.x.features.login.error.loginError
import io.element.android.x.matrix.core.SessionId import io.element.android.x.matrix.core.SessionId
import timber.log.Timber
@Composable
fun LoginScreen(
viewModel: LoginViewModel = mavericksViewModel(),
onChangeServer: () -> Unit = { },
onLoginWithSuccess: (SessionId) -> Unit = { },
) {
val state: LoginViewState by viewModel.collectAsState()
val formState: LoginFormState by viewModel.formState
LaunchedEffect(key1 = Unit) {
Timber.d("resume")
viewModel.onResume()
}
LoginContent(
state = state,
formState = formState,
onChangeServer = onChangeServer,
onLoginChanged = viewModel::onSetName,
onPasswordChanged = viewModel::onSetPassword,
onSubmitClicked = viewModel::onSubmit,
onLoginWithSuccess = onLoginWithSuccess
)
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LoginContent( fun LoginRootScreen(
state: LoginViewState, state: LoginRootState,
formState: LoginFormState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onChangeServer: () -> Unit = {}, onChangeServer: () -> Unit = {},
onLoginChanged: (String) -> Unit = {}, onLoginChanged: (String) -> Unit = {},
@@ -98,6 +66,9 @@ fun LoginContent(
.imePadding() .imePadding()
) { ) {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
var loginFieldState by textFieldState(stateValue = state.formState.login)
var passwordFieldState by textFieldState(stateValue = state.formState.password)
Column( Column(
modifier = Modifier modifier = Modifier
.verticalScroll( .verticalScroll(
@@ -105,7 +76,7 @@ fun LoginContent(
) )
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) { ) {
val isError = state.loggedInSessionId is Fail val isError = state.loggedInState is LoggedInState.ErrorLoggingIn
// Title // Title
Text( Text(
text = "Welcome back", text = "Welcome back",
@@ -146,30 +117,36 @@ fun LoginContent(
) )
} }
OutlinedTextField( OutlinedTextField(
value = formState.login, value = loginFieldState,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 60.dp), .padding(top = 60.dp),
label = { label = {
Text(text = "Email or username") Text(text = "Email or username")
}, },
onValueChange = onLoginChanged, onValueChange = {
loginFieldState = it
onLoginChanged(it)
},
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email, keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next imeAction = ImeAction.Next
), ),
) )
var passwordVisible by remember { mutableStateOf(false) } var passwordVisible by remember { mutableStateOf(false) }
if (state.loggedInSessionId is Loading) { if (state.loggedInState is LoggedInState.LoggingIn) {
// Ensure password is hidden when user submits the form // Ensure password is hidden when user submits the form
passwordVisible = false passwordVisible = false
} }
OutlinedTextField( OutlinedTextField(
value = formState.password, value = passwordFieldState,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 24.dp), .padding(top = 24.dp),
onValueChange = onPasswordChanged, onValueChange = {
passwordFieldState = it
onPasswordChanged(it)
},
label = { label = {
Text(text = "Password") Text(text = "Password")
}, },
@@ -193,9 +170,9 @@ fun LoginContent(
onDone = { onSubmitClicked() } onDone = { onSubmitClicked() }
), ),
) )
if (state.loggedInSessionId is Fail) { if (state.loggedInState is LoggedInState.ErrorLoggingIn) {
Text( Text(
text = loginError(state.formState, state.loggedInSessionId.error), text = loginError(state.formState, state.loggedInState.failure),
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp) modifier = Modifier.padding(start = 16.dp)
@@ -212,12 +189,12 @@ fun LoginContent(
) { ) {
Text(text = "Continue") Text(text = "Continue")
} }
when (val loggedInSessionId = state.loggedInSessionId) { when (val loggedInState = state.loggedInState) {
is Success -> onLoginWithSuccess(loggedInSessionId()) is LoggedInState.LoggedIn -> onLoginWithSuccess(loggedInState.sessionId)
else -> Unit else -> Unit
} }
} }
if (state.loggedInSessionId is Loading) { if (state.loggedInState is LoggedInState.LoggingIn) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center) modifier = Modifier.align(Alignment.Center)
) )
@@ -230,11 +207,10 @@ fun LoginContent(
@Preview @Preview
fun LoginContentPreview() { fun LoginContentPreview() {
ElementXTheme(darkTheme = false) { ElementXTheme(darkTheme = false) {
LoginContent( LoginRootScreen(
state = LoginViewState( state = LoginRootState(
homeserver = "matrix.org", homeserver = "matrix.org",
), ),
formState = LoginFormState("", "")
) )
} }
} }
@@ -0,0 +1,32 @@
package io.element.android.x.features.login.root
import android.os.Parcelable
import io.element.android.x.matrix.core.SessionId
import kotlinx.parcelize.Parcelize
data class LoginRootState(
val homeserver: String = "",
val loggedInState: LoggedInState = LoggedInState.NotLoggedIn,
val formState: LoginFormState = LoginFormState.Default,
) {
val submitEnabled =
formState.login.isNotEmpty() && formState.password.isNotEmpty() && loggedInState != LoggedInState.LoggingIn
}
sealed interface LoggedInState {
object NotLoggedIn : LoggedInState
object LoggingIn : LoggedInState
data class ErrorLoggingIn(val failure: Throwable) : LoggedInState
data class LoggedIn(val sessionId: SessionId) : LoggedInState
}
@Parcelize
data class LoginFormState(
val login: String,
val password: String
) : Parcelable {
companion object {
val Default = LoginFormState("", "")
}
}
@@ -29,8 +29,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.airbnb.mvrx.compose.collectAsState
import com.airbnb.mvrx.compose.mavericksViewModel
import com.google.accompanist.pager.ExperimentalPagerApi import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.HorizontalPagerIndicator import com.google.accompanist.pager.HorizontalPagerIndicator
@@ -39,25 +37,9 @@ import io.element.android.x.designsystem.components.VectorButton
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable
fun OnBoardingScreen(
viewModel: OnBoardingViewModel = mavericksViewModel(),
onSignUp: () -> Unit = { },
onSignIn: () -> Unit = { },
) {
val state: OnBoardingViewState by viewModel.collectAsState()
OnBoardingContent(
state,
onPageChanged = viewModel::onPageChanged,
onSignUp = onSignUp,
onSignIn = onSignIn,
)
}
@OptIn(ExperimentalPagerApi::class) @OptIn(ExperimentalPagerApi::class)
@Composable @Composable
fun OnBoardingContent( fun OnBoardingScreen(
state: OnBoardingViewState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onPageChanged: (Int) -> Unit = {}, onPageChanged: (Int) -> Unit = {},
onSignUp: () -> Unit = {}, onSignUp: () -> Unit = {},
@@ -1,15 +0,0 @@
package io.element.android.x.features.onboarding
import com.airbnb.mvrx.MavericksViewModel
class OnBoardingViewModel(initialState: OnBoardingViewState) :
MavericksViewModel<OnBoardingViewState>(initialState) {
fun onPageChanged(page: Int) {
setState {
copy(
currentPage = page,
)
}
}
}
@@ -1,7 +0,0 @@
package io.element.android.x.features.onboarding
import com.airbnb.mvrx.MavericksState
data class OnBoardingViewState(
val currentPage: Int = 0,
) : MavericksState
@@ -6,6 +6,7 @@ import io.element.android.x.element.resources.R as ElementR
class SplashCarouselStateFactory { class SplashCarouselStateFactory {
fun create(): SplashCarouselState { fun create(): SplashCarouselState {
val lightTheme = true val lightTheme = true
fun background(@DrawableRes lightDrawable: Int) = fun background(@DrawableRes lightDrawable: Int) =
if (lightTheme) lightDrawable else R.drawable.bg_color_background if (lightTheme) lightDrawable else R.drawable.bg_color_background
@@ -27,7 +27,7 @@ class RoomListNode @AssistedInject constructor(
fun onRoomClicked(roomId: RoomId) fun onRoomClicked(roomId: RoomId)
} }
private val connector by presenterConnector(presenter) private val connector = presenterConnector(presenter)
private fun updateFilter(filter: String) { private fun updateFilter(filter: String) {
connector.emitEvent(RoomListEvents.UpdateFilter(filter)) connector.emitEvent(RoomListEvents.UpdateFilter(filter))
@@ -0,0 +1,31 @@
package io.element.android.x.architecture
import androidx.compose.runtime.MutableState
sealed interface Async<out T> {
object Uninitialized : Async<Nothing>
data class Loading<out T>(val prevState: T? = null) : Async<T>
data class Failure<out T>(val error: Throwable) : Async<T>
data class Success<out T>(val state: T) : Async<T>
}
suspend fun <T> (suspend () -> T).execute(state: MutableState<Async<T>>) {
try {
state.value = Async.Loading()
state.value = Async.Success(this())
} catch (error: Throwable) {
state.value = Async.Failure(error)
}
}
suspend fun <T> (suspend () -> Result<T>).executeResult(state: MutableState<Async<T>>) {
state.value = Async.Loading()
this().fold(
onSuccess = {
state.value = Async.Success(it)
},
onFailure = {
state.value = Async.Failure(it)
}
)
}
@@ -8,21 +8,22 @@ import app.cash.molecule.launchMolecule
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
inline fun <reified State, reified Event> LifecycleOwner.presenterConnector(presenter: Presenter<State, Event>): Lazy<LifecyclePresenterConnector<State, Event>> = lazy { inline fun <reified State, reified Event> LifecycleOwner.presenterConnector(presenter: Presenter<State, Event>): LifecyclePresenterConnector<State, Event> =
LifecyclePresenterConnector(lifecycleOwner = this, presenter = presenter) LifecyclePresenterConnector(lifecycleOwner = this, presenter = presenter)
}
class LifecyclePresenterConnector<State, Event>(lifecycleOwner: LifecycleOwner, presenter: Presenter<State, Event>) { class LifecyclePresenterConnector<State, Event>(lifecycleOwner: LifecycleOwner, presenter: Presenter<State, Event>) {
private val moleculeScope = CoroutineScope(lifecycleOwner.lifecycleScope.coroutineContext + AndroidUiDispatcher.Main) private val moleculeScope = CoroutineScope(lifecycleOwner.lifecycleScope.coroutineContext + AndroidUiDispatcher.Main)
private val eventFlow: MutableSharedFlow<Event> = MutableSharedFlow(extraBufferCapacity = 64) private val mutableEventFlow: MutableSharedFlow<Event> = MutableSharedFlow(extraBufferCapacity = 64)
val stateFlow: StateFlow<State> = moleculeScope.launchMolecule(RecompositionClock.ContextClock) { val stateFlow: StateFlow<State> = moleculeScope.launchMolecule(RecompositionClock.Immediate) {
presenter.present(events = eventFlow) presenter.present(events = mutableEventFlow)
} }
fun emitEvent(event: Event) { fun emitEvent(event: Event) {
eventFlow.tryEmit(event) mutableEventFlow.tryEmit(event)
} }
} }