diff --git a/app/src/main/java/io/element/android/x/di/AppComponent.kt b/app/src/main/java/io/element/android/x/di/AppComponent.kt index 9b9443b3fc..fb6e924672 100644 --- a/app/src/main/java/io/element/android/x/di/AppComponent.kt +++ b/app/src/main/java/io/element/android/x/di/AppComponent.kt @@ -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 { diff --git a/app/src/main/java/io/element/android/x/node/NotLoggedInFlowNode.kt b/app/src/main/java/io/element/android/x/node/NotLoggedInFlowNode.kt index 65d830dd99..4f346bdbdd 100644 --- a/app/src/main/java/io/element/android/x/node/NotLoggedInFlowNode.kt +++ b/app/src/main/java/io/element/android/x/node/NotLoggedInFlowNode.kt @@ -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) } ) diff --git a/features/login/src/main/java/io/element/android/x/features/login/node/LoginFlowNode.kt b/features/login/src/main/java/io/element/android/x/features/login/LoginFlowNode.kt similarity index 65% rename from features/login/src/main/java/io/element/android/x/features/login/node/LoginFlowNode.kt rename to features/login/src/main/java/io/element/android/x/features/login/LoginFlowNode.kt index 703f2f7aec..5f1fcbfea9 100644 --- a/features/login/src/main/java/io/element/android/x/features/login/node/LoginFlowNode.kt +++ b/features/login/src/main/java/io/element/android/x/features/login/LoginFlowNode.kt @@ -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(buildContext, plugins = listOf(loginRootCallback)) + NavTarget.ChangeServer -> createNode(buildContext) } } @@ -53,5 +50,4 @@ class LoginFlowNode( override fun View(modifier: Modifier) { Children(navModel = backstack) } - } diff --git a/features/login/src/main/java/io/element/android/x/features/login/LoginViewModel.kt b/features/login/src/main/java/io/element/android/x/features/login/LoginViewModel.kt deleted file mode 100644 index 3900a27c49..0000000000 --- a/features/login/src/main/java/io/element/android/x/features/login/LoginViewModel.kt +++ /dev/null @@ -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(initialState) { - - companion object : MavericksViewModelFactory 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) } - } -} diff --git a/features/login/src/main/java/io/element/android/x/features/login/LoginViewState.kt b/features/login/src/main/java/io/element/android/x/features/login/LoginViewState.kt deleted file mode 100644 index 27a15945a6..0000000000 --- a/features/login/src/main/java/io/element/android/x/features/login/LoginViewState.kt +++ /dev/null @@ -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 = 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("", "") - } -} diff --git a/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerEvents.kt b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerEvents.kt new file mode 100644 index 0000000000..893459b7cc --- /dev/null +++ b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerEvents.kt @@ -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 +} diff --git a/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerNode.kt b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerNode.kt new file mode 100644 index 0000000000..f9f0bc4bbd --- /dev/null +++ b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerNode.kt @@ -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, + 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, + ) + } +} diff --git a/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerPresenter.kt b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerPresenter.kt new file mode 100644 index 0000000000..c5d9891c7c --- /dev/null +++ b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerPresenter.kt @@ -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 { + + @Composable + override fun present(events: Flow): ChangeServerState { + val homeserver = rememberSaveable { + mutableStateOf(matrix.getHomeserverOrDefault()) + } + val changeServerAction: MutableState> = 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>) = launch { + suspend { + matrix.setHomeserver(homeserver) + }.execute(changeServerAction) + } +} diff --git a/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerState.kt b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerState.kt new file mode 100644 index 0000000000..dabd7a09bf --- /dev/null +++ b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerState.kt @@ -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 = Async.Uninitialized, +) { + val submitEnabled = homeserver.isNotEmpty() && changeServerAction !is Async.Loading +} diff --git a/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerScreen.kt b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerView.kt similarity index 85% rename from features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerScreen.kt rename to features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerView.kt index 3918d2f6c5..6c7b63f06c 100644 --- a/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerScreen.kt +++ b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerView.kt @@ -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"), ) } } diff --git a/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerViewModel.kt b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerViewModel.kt deleted file mode 100644 index 91eeb2b589..0000000000 --- a/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerViewModel.kt +++ /dev/null @@ -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(initialState) { - - companion object : - MavericksViewModelFactory 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) - } - } - } -} diff --git a/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerViewState.kt b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerViewState.kt deleted file mode 100644 index 8e1f907c31..0000000000 --- a/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerViewState.kt +++ /dev/null @@ -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 = Uninitialized, -) : MavericksState { - val submitEnabled = homeserver.isNotEmpty() && changeServerAction !is Loading -} diff --git a/features/login/src/main/java/io/element/android/x/features/login/error/ErrorFormatter.kt b/features/login/src/main/java/io/element/android/x/features/login/error/ErrorFormatter.kt index 6c26a9aff1..32b801d95a 100644 --- a/features/login/src/main/java/io/element/android/x/features/login/error/ErrorFormatter.kt +++ b/features/login/src/main/java/io/element/android/x/features/login/error/ErrorFormatter.kt @@ -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( diff --git a/features/login/src/main/java/io/element/android/x/features/login/root/LoginRootEvents.kt b/features/login/src/main/java/io/element/android/x/features/login/root/LoginRootEvents.kt new file mode 100644 index 0000000000..9c7f401060 --- /dev/null +++ b/features/login/src/main/java/io/element/android/x/features/login/root/LoginRootEvents.kt @@ -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 +} diff --git a/features/login/src/main/java/io/element/android/x/features/login/root/LoginRootNode.kt b/features/login/src/main/java/io/element/android/x/features/login/root/LoginRootNode.kt new file mode 100644 index 0000000000..096fb3939a --- /dev/null +++ b/features/login/src/main/java/io/element/android/x/features/login/root/LoginRootNode.kt @@ -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, + 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().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 + ) + } +} diff --git a/features/login/src/main/java/io/element/android/x/features/login/root/LoginRootPresenter.kt b/features/login/src/main/java/io/element/android/x/features/login/root/LoginRootPresenter.kt new file mode 100644 index 0000000000..1d254c47d3 --- /dev/null +++ b/features/login/src/main/java/io/element/android/x/features/login/root/LoginRootPresenter.kt @@ -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 { + + @Composable + override fun present(events: Flow): LoginRootState { + val homeserver = rememberSaveable { + mutableStateOf(matrix.getHomeserverOrDefault()) + } + val loggedInState: MutableState = 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) = 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, updateLambda: LoginFormState.() -> LoginFormState) { + formState.value = updateLambda(formState.value) + } + + private fun refreshHomeServer(homeserver: MutableState) { + homeserver.value = matrix.getHomeserverOrDefault() + } +} diff --git a/features/login/src/main/java/io/element/android/x/features/login/LoginScreen.kt b/features/login/src/main/java/io/element/android/x/features/login/root/LoginRootScreen.kt similarity index 80% rename from features/login/src/main/java/io/element/android/x/features/login/LoginScreen.kt rename to features/login/src/main/java/io/element/android/x/features/login/root/LoginRootScreen.kt index 19c5821a2e..4403c2c972 100644 --- a/features/login/src/main/java/io/element/android/x/features/login/LoginScreen.kt +++ b/features/login/src/main/java/io/element/android/x/features/login/root/LoginRootScreen.kt @@ -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("", "") ) } } diff --git a/features/login/src/main/java/io/element/android/x/features/login/root/LoginRootState.kt b/features/login/src/main/java/io/element/android/x/features/login/root/LoginRootState.kt new file mode 100644 index 0000000000..38b8f67af2 --- /dev/null +++ b/features/login/src/main/java/io/element/android/x/features/login/root/LoginRootState.kt @@ -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("", "") + } +} diff --git a/features/onboarding/src/main/java/io/element/android/x/features/onboarding/OnBoardingScreen.kt b/features/onboarding/src/main/java/io/element/android/x/features/onboarding/OnBoardingScreen.kt index 55d3375a4a..a1907929d9 100644 --- a/features/onboarding/src/main/java/io/element/android/x/features/onboarding/OnBoardingScreen.kt +++ b/features/onboarding/src/main/java/io/element/android/x/features/onboarding/OnBoardingScreen.kt @@ -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 = {}, diff --git a/features/onboarding/src/main/java/io/element/android/x/features/onboarding/OnBoardingViewModel.kt b/features/onboarding/src/main/java/io/element/android/x/features/onboarding/OnBoardingViewModel.kt deleted file mode 100644 index b1f708b126..0000000000 --- a/features/onboarding/src/main/java/io/element/android/x/features/onboarding/OnBoardingViewModel.kt +++ /dev/null @@ -1,15 +0,0 @@ -package io.element.android.x.features.onboarding - -import com.airbnb.mvrx.MavericksViewModel - -class OnBoardingViewModel(initialState: OnBoardingViewState) : - MavericksViewModel(initialState) { - - fun onPageChanged(page: Int) { - setState { - copy( - currentPage = page, - ) - } - } -} diff --git a/features/onboarding/src/main/java/io/element/android/x/features/onboarding/OnBoardingViewState.kt b/features/onboarding/src/main/java/io/element/android/x/features/onboarding/OnBoardingViewState.kt deleted file mode 100644 index 0262bdd339..0000000000 --- a/features/onboarding/src/main/java/io/element/android/x/features/onboarding/OnBoardingViewState.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.element.android.x.features.onboarding - -import com.airbnb.mvrx.MavericksState - -data class OnBoardingViewState( - val currentPage: Int = 0, -) : MavericksState diff --git a/features/onboarding/src/main/java/io/element/android/x/features/onboarding/SplashCarouselStateFactory.kt b/features/onboarding/src/main/java/io/element/android/x/features/onboarding/SplashCarouselStateFactory.kt index 7ed1951ce2..ddefc79cc1 100644 --- a/features/onboarding/src/main/java/io/element/android/x/features/onboarding/SplashCarouselStateFactory.kt +++ b/features/onboarding/src/main/java/io/element/android/x/features/onboarding/SplashCarouselStateFactory.kt @@ -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 diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListNode.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListNode.kt index 8a994fcd07..06b089b807 100644 --- a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListNode.kt +++ b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListNode.kt @@ -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)) diff --git a/libraries/architecture/src/main/java/io/element/android/x/architecture/Async.kt b/libraries/architecture/src/main/java/io/element/android/x/architecture/Async.kt new file mode 100644 index 0000000000..206386630d --- /dev/null +++ b/libraries/architecture/src/main/java/io/element/android/x/architecture/Async.kt @@ -0,0 +1,31 @@ +package io.element.android.x.architecture + +import androidx.compose.runtime.MutableState + +sealed interface Async { + object Uninitialized : Async + data class Loading(val prevState: T? = null) : Async + data class Failure(val error: Throwable) : Async + data class Success(val state: T) : Async +} + +suspend fun (suspend () -> T).execute(state: MutableState>) { + try { + state.value = Async.Loading() + state.value = Async.Success(this()) + } catch (error: Throwable) { + state.value = Async.Failure(error) + } +} + +suspend fun (suspend () -> Result).executeResult(state: MutableState>) { + state.value = Async.Loading() + this().fold( + onSuccess = { + state.value = Async.Success(it) + }, + onFailure = { + state.value = Async.Failure(it) + } + ) +} diff --git a/libraries/architecture/src/main/java/io/element/android/x/architecture/LifecyclePresenterConnector.kt b/libraries/architecture/src/main/java/io/element/android/x/architecture/PresenterConnector.kt similarity index 70% rename from libraries/architecture/src/main/java/io/element/android/x/architecture/LifecyclePresenterConnector.kt rename to libraries/architecture/src/main/java/io/element/android/x/architecture/PresenterConnector.kt index dcca215f07..022a0a1558 100644 --- a/libraries/architecture/src/main/java/io/element/android/x/architecture/LifecyclePresenterConnector.kt +++ b/libraries/architecture/src/main/java/io/element/android/x/architecture/PresenterConnector.kt @@ -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 LifecycleOwner.presenterConnector(presenter: Presenter): Lazy> = lazy { +inline fun LifecycleOwner.presenterConnector(presenter: Presenter): LifecyclePresenterConnector = LifecyclePresenterConnector(lifecycleOwner = this, presenter = presenter) -} + class LifecyclePresenterConnector(lifecycleOwner: LifecycleOwner, presenter: Presenter) { private val moleculeScope = CoroutineScope(lifecycleOwner.lifecycleScope.coroutineContext + AndroidUiDispatcher.Main) - private val eventFlow: MutableSharedFlow = MutableSharedFlow(extraBufferCapacity = 64) + private val mutableEventFlow: MutableSharedFlow = MutableSharedFlow(extraBufferCapacity = 64) - val stateFlow: StateFlow = moleculeScope.launchMolecule(RecompositionClock.ContextClock) { - presenter.present(events = eventFlow) + val stateFlow: StateFlow = moleculeScope.launchMolecule(RecompositionClock.Immediate) { + presenter.present(events = mutableEventFlow) } fun emitEvent(event: Event) { - eventFlow.tryEmit(event) + mutableEventFlow.tryEmit(event) } }