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

View file

@ -4,11 +4,12 @@ import android.content.Context
import com.squareup.anvil.annotations.MergeComponent
import dagger.BindsInstance
import dagger.Component
import io.element.android.x.architecture.NodeFactoriesBindings
import io.element.android.x.architecture.viewmodel.DaggerMavericksBindings
@SingleIn(AppScope::class)
@MergeComponent(AppScope::class)
interface AppComponent : DaggerMavericksBindings {
interface AppComponent : DaggerMavericksBindings, NodeFactoriesBindings {
@Component.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.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.architecture.viewmodel.viewModelSupportNode
import io.element.android.x.features.login.node.LoginFlowNode
import io.element.android.x.features.login.LoginFlowNode
import io.element.android.x.features.onboarding.OnBoardingScreen
import kotlinx.parcelize.Parcelize
import timber.log.Timber
@ -44,7 +44,7 @@ class NotLoggedInFlowNode(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.OnBoarding -> viewModelSupportNode(buildContext) {
NavTarget.OnBoarding -> node(buildContext) {
OnBoardingScreen(
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 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.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.architecture.viewmodel.viewModelSupportNode
import io.element.android.x.features.login.LoginScreen
import io.element.android.x.features.login.changeserver.ChangeServerScreen
import io.element.android.x.architecture.createNode
import io.element.android.x.features.login.changeserver.ChangeServerNode
import io.element.android.x.features.login.root.LoginRootNode
import kotlinx.parcelize.Parcelize
class LoginFlowNode(
@ -26,6 +25,12 @@ class LoginFlowNode(
buildContext = buildContext
) {
private val loginRootCallback = object : LoginRootNode.Callback {
override fun onChangeHomeServer() {
backstack.push(NavTarget.ChangeServer)
}
}
sealed interface NavTarget : Parcelable {
@Parcelize
object Root : NavTarget
@ -36,16 +41,8 @@ class LoginFlowNode(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Root -> viewModelSupportNode(buildContext) {
LoginScreen(
onChangeServer = { backstack.push(NavTarget.ChangeServer) }
)
}
NavTarget.ChangeServer -> viewModelSupportNode(buildContext) {
ChangeServerScreen(
onChangeServerSuccess = { backstack.pop() }
)
}
NavTarget.Root -> createNode<LoginRootNode>(buildContext, plugins = listOf(loginRootCallback))
NavTarget.ChangeServer -> createNode<ChangeServerNode>(buildContext)
}
}
@ -53,5 +50,4 @@ class LoginFlowNode(
override fun View(modifier: Modifier) {
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.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.unit.dp
import androidx.compose.ui.unit.sp
import com.airbnb.mvrx.Fail
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.architecture.Async
import io.element.android.x.core.compose.textFieldState
import io.element.android.x.designsystem.ElementXTheme
import io.element.android.x.designsystem.components.VectorIcon
import io.element.android.x.features.login.R
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
fun ChangeServerContent(
state: ChangeServerViewState,
fun ChangeServerView(
state: ChangeServerState,
modifier: Modifier = Modifier,
onChangeServer: (String) -> Unit = {},
onChangeServerSubmit: () -> Unit = {},
@ -85,7 +70,7 @@ fun ChangeServerContent(
)
.padding(horizontal = 16.dp)
) {
val isError = state.changeServerAction is Fail
val isError = state.changeServerAction is Async.Failure
Box(
modifier = Modifier
.padding(top = 99.dp)
@ -126,12 +111,16 @@ fun ChangeServerContent(
fontSize = 16.sp,
color = MaterialTheme.colorScheme.secondary
)
var homeserverFieldState by textFieldState(stateValue = state.homeserver)
OutlinedTextField(
value = state.homeserver,
value = homeserverFieldState,
modifier = Modifier
.fillMaxWidth()
.padding(top = 200.dp),
onValueChange = onChangeServer,
onValueChange = {
homeserverFieldState = it
onChangeServer(it)
},
label = {
Text(text = "Server")
},
@ -144,7 +133,7 @@ fun ChangeServerContent(
onDone = { onChangeServerSubmit() }
)
)
if (state.changeServerAction is Fail) {
if (state.changeServerAction is Async.Failure) {
Text(
text = changeServerError(
state.homeserver,
@ -164,11 +153,11 @@ fun ChangeServerContent(
) {
Text(text = "Continue")
}
if (state.changeServerAction is Success) {
if (state.changeServerAction is Async.Success) {
onChangeServerSuccess()
}
}
if (state.changeServerAction is Loading) {
if (state.changeServerAction is Async.Loading) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
@ -181,8 +170,8 @@ fun ChangeServerContent(
@Preview
fun ChangeServerContentPreview() {
ElementXTheme {
ChangeServerContent(
state = ChangeServerViewState(homeserver = "matrix.org"),
ChangeServerView(
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.ui.res.stringResource
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.features.login.LoginFormState
@Composable
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
package io.element.android.x.features.login.root
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -26,7 +24,6 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
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
@ -42,44 +39,15 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.airbnb.mvrx.Fail
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.core.compose.textFieldState
import io.element.android.x.designsystem.ElementXTheme
import io.element.android.x.features.login.error.loginError
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)
@Composable
fun LoginContent(
state: LoginViewState,
formState: LoginFormState,
fun LoginRootScreen(
state: LoginRootState,
modifier: Modifier = Modifier,
onChangeServer: () -> Unit = {},
onLoginChanged: (String) -> Unit = {},
@ -98,6 +66,9 @@ fun LoginContent(
.imePadding()
) {
val scrollState = rememberScrollState()
var loginFieldState by textFieldState(stateValue = state.formState.login)
var passwordFieldState by textFieldState(stateValue = state.formState.password)
Column(
modifier = Modifier
.verticalScroll(
@ -105,7 +76,7 @@ fun LoginContent(
)
.padding(horizontal = 16.dp),
) {
val isError = state.loggedInSessionId is Fail
val isError = state.loggedInState is LoggedInState.ErrorLoggingIn
// Title
Text(
text = "Welcome back",
@ -146,30 +117,36 @@ fun LoginContent(
)
}
OutlinedTextField(
value = formState.login,
value = loginFieldState,
modifier = Modifier
.fillMaxWidth()
.padding(top = 60.dp),
label = {
Text(text = "Email or username")
},
onValueChange = onLoginChanged,
onValueChange = {
loginFieldState = it
onLoginChanged(it)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
)
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
passwordVisible = false
}
OutlinedTextField(
value = formState.password,
value = passwordFieldState,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
onValueChange = onPasswordChanged,
onValueChange = {
passwordFieldState = it
onPasswordChanged(it)
},
label = {
Text(text = "Password")
},
@ -193,9 +170,9 @@ fun LoginContent(
onDone = { onSubmitClicked() }
),
)
if (state.loggedInSessionId is Fail) {
if (state.loggedInState is LoggedInState.ErrorLoggingIn) {
Text(
text = loginError(state.formState, state.loggedInSessionId.error),
text = loginError(state.formState, state.loggedInState.failure),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp)
@ -212,12 +189,12 @@ fun LoginContent(
) {
Text(text = "Continue")
}
when (val loggedInSessionId = state.loggedInSessionId) {
is Success -> onLoginWithSuccess(loggedInSessionId())
when (val loggedInState = state.loggedInState) {
is LoggedInState.LoggedIn -> onLoginWithSuccess(loggedInState.sessionId)
else -> Unit
}
}
if (state.loggedInSessionId is Loading) {
if (state.loggedInState is LoggedInState.LoggingIn) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
@ -230,11 +207,10 @@ fun LoginContent(
@Preview
fun LoginContentPreview() {
ElementXTheme(darkTheme = false) {
LoginContent(
state = LoginViewState(
LoginRootScreen(
state = LoginRootState(
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.unit.dp
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.HorizontalPager
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.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)
@Composable
fun OnBoardingContent(
state: OnBoardingViewState,
fun OnBoardingScreen(
modifier: Modifier = Modifier,
onPageChanged: (Int) -> 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 {
fun create(): SplashCarouselState {
val lightTheme = true
fun background(@DrawableRes lightDrawable: Int) =
if (lightTheme) lightDrawable else R.drawable.bg_color_background

View file

@ -27,7 +27,7 @@ class RoomListNode @AssistedInject constructor(
fun onRoomClicked(roomId: RoomId)
}
private val connector by presenterConnector(presenter)
private val connector = presenterConnector(presenter)
private fun updateFilter(filter: String) {
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.flow.MutableSharedFlow
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)
}
class LifecyclePresenterConnector<State, Event>(lifecycleOwner: LifecycleOwner, presenter: Presenter<State, Event>) {
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) {
presenter.present(events = eventFlow)
val stateFlow: StateFlow<State> = moleculeScope.launchMolecule(RecompositionClock.Immediate) {
presenter.present(events = mutableEventFlow)
}
fun emitEvent(event: Event) {
eventFlow.tryEmit(event)
mutableEventFlow.tryEmit(event)
}
}