Migrate Login to new architecture and make some adjustments
This commit is contained in:
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 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 {
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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.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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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("", "")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.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 = {},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
fun create(): SplashCarouselState {
|
||||
val lightTheme = true
|
||||
|
||||
fun background(@DrawableRes lightDrawable: Int) =
|
||||
if (lightTheme) lightDrawable else R.drawable.bg_color_background
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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.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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue