Migrate Login to new architecture and make some adjustments

This commit is contained in:
ganfra 2023-01-06 15:15:45 +01:00
parent f4ac368d07
commit 9ed208950e
25 changed files with 385 additions and 304 deletions

View file

@ -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 {

View file

@ -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) }
) )

View file

@ -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)
} }
} }

View file

@ -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) }
}
}

View file

@ -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("", "")
}
}

View file

@ -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
}

View file

@ -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,
)
}
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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"),
) )
} }
} }

View file

@ -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)
}
}
}
}

View file

@ -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
}

View file

@ -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(

View file

@ -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
}

View file

@ -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
)
}
}

View file

@ -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()
}
}

View file

@ -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("", "")
) )
} }
} }

View file

@ -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("", "")
}
}

View file

@ -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 = {},

View file

@ -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,
)
}
}
}

View file

@ -1,7 +0,0 @@
package io.element.android.x.features.onboarding
import com.airbnb.mvrx.MavericksState
data class OnBoardingViewState(
val currentPage: Int = 0,
) : MavericksState

View file

@ -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

View file

@ -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))

View file

@ -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)
}
)
}

View file

@ -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)
} }
} }