From dc3ad323e5b08a4f3a9ad8bbee8faf78b7249a81 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 6 Jun 2023 18:16:35 +0200 Subject: [PATCH] LoginPasswordNode --- .../features/login/impl/LoginFlowNode.kt | 22 +- .../accountprovider/AccountProviderNode.kt | 15 +- .../AccountProviderPresenter.kt | 41 ++- .../accountprovider/AccountProviderState.kt | 11 +- .../AccountProviderStateProvider.kt | 2 +- .../accountprovider/AccountProviderView.kt | 37 ++- .../impl/loginpassword/LoginPasswordEvents.kt | 24 ++ .../impl/loginpassword/LoginPasswordNode.kt | 45 +++ .../loginpassword/LoginPasswordPresenter.kt | 84 +++++ .../impl/loginpassword/LoginPasswordState.kt | 43 +++ .../LoginPasswordStateProvider.kt | 37 +++ .../impl/loginpassword/LoginPasswordView.kt | 305 ++++++++++++++++++ 12 files changed, 636 insertions(+), 30 deletions(-) create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordEvents.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordNode.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordPresenter.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordState.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordStateProvider.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordView.kt diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt index 75681099b1..8f0564cf33 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt @@ -38,6 +38,7 @@ import io.element.android.features.login.impl.changeaccountprovider.form.ChangeA import io.element.android.features.login.impl.changeaccountprovider.item.AccountProviderItem import io.element.android.features.login.impl.changeserver.ChangeServerNode import io.element.android.features.login.impl.datasource.AccountProviderDataSource +import io.element.android.features.login.impl.loginpassword.LoginPasswordNode import io.element.android.features.login.impl.oidc.CustomTabAvailabilityChecker import io.element.android.features.login.impl.oidc.customtab.CustomTabHandler import io.element.android.features.login.impl.oidc.webview.OidcNode @@ -90,6 +91,9 @@ class LoginFlowNode @AssistedInject constructor( @Parcelize object ChangeAccountProviderForm : NavTarget + @Parcelize + object LoginPasswordForm : NavTarget + // Not used anymore @Parcelize object ChangeServer : NavTarget @@ -129,9 +133,18 @@ class LoginFlowNode @AssistedInject constructor( isAccountCreation = inputs.isAccountCreation ) val callback = object : AccountProviderNode.Callback { - override fun onServerValidated() { - // TODO - TODO("Not yet implemented") + override fun onOidcDetails(oidcDetails: OidcDetails) { + if (customTabAvailabilityChecker.supportCustomTab()) { + // In this case open a Chrome Custom tab + activity?.let { customTabHandler.open(it, darkTheme, oidcDetails.url) } + } else { + // Fallback to WebView mode + backstack.push(NavTarget.OidcView(oidcDetails)) + } + } + + override fun onLoginPasswordNeeded() { + backstack.push(NavTarget.LoginPasswordForm) } override fun onChangeAccountProvider() { @@ -166,6 +179,9 @@ class LoginFlowNode @AssistedInject constructor( createNode(buildContext, plugins = listOf(callback)) } + NavTarget.LoginPasswordForm -> { + createNode(buildContext, plugins = listOf()) + } } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderNode.kt index 3331080f23..aac5068294 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderNode.kt @@ -34,6 +34,7 @@ import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.inputs import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.auth.OidcDetails @ContributesNode(AppScope::class) class AccountProviderNode @AssistedInject constructor( @@ -54,12 +55,17 @@ class AccountProviderNode @AssistedInject constructor( ) interface Callback : Plugin { - fun onServerValidated() + fun onLoginPasswordNeeded() + fun onOidcDetails(oidcDetails: OidcDetails) fun onChangeAccountProvider() } - private fun onServerValidated() { - plugins().forEach { it.onServerValidated() } + private fun onOidcDetails(data: OidcDetails) { + plugins().forEach { it.onOidcDetails(data) } + } + + private fun onLoginPasswordNeeded() { + plugins().forEach { it.onLoginPasswordNeeded() } } private fun onChangeAccountProvider() { @@ -78,7 +84,8 @@ class AccountProviderNode @AssistedInject constructor( AccountProviderView( state = state, modifier = modifier, - onChangeServerSuccess = ::onServerValidated, + onOidcDetails = ::onOidcDetails, + onLoginPasswordNeeded = ::onLoginPasswordNeeded, onChange = ::onChangeAccountProvider, onLearnMoreClicked = { openLearnMorePage(context) }, ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderPresenter.kt index 3561e2f19a..b20a69fcae 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderPresenter.kt @@ -29,11 +29,13 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import io.element.android.features.login.impl.changeserver.ChangeServerError import io.element.android.features.login.impl.datasource.AccountProviderDataSource +import io.element.android.features.login.impl.util.LoginConstants import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.execute import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import java.net.URL @@ -56,22 +58,29 @@ class AccountProviderPresenter @AssistedInject constructor( @Composable override fun present(): AccountProviderState { val accountProvider by accountProviderDataSource.flow().collectAsState() - + val currentHomeServerDetails = authenticationService.getHomeserverDetails().collectAsState().value + val getHomeServerDetailsAction: MutableState> = remember { + if (currentHomeServerDetails != null) { + mutableStateOf(Async.Success(currentHomeServerDetails)) + } else { + mutableStateOf(Async.Uninitialized) + } + } val localCoroutineScope = rememberCoroutineScope() val homeserver = rememberSaveable { - mutableStateOf(accountProvider.title /* TODO There is a mix of data and UI here, which is not nice */) + mutableStateOf(currentHomeServerDetails?.url ?: LoginConstants.DEFAULT_HOMESERVER_URL) } - val changeServerAction: MutableState> = remember { + val loginFlowAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) } fun handleEvents(event: AccountProviderEvents) { when (event) { AccountProviderEvents.Continue -> { - localCoroutineScope.submit(homeserver, changeServerAction) + localCoroutineScope.submit(homeserver, loginFlowAction) } - AccountProviderEvents.ClearError -> changeServerAction.value = Async.Uninitialized + AccountProviderEvents.ClearError -> loginFlowAction.value = Async.Uninitialized } } @@ -79,16 +88,30 @@ class AccountProviderPresenter @AssistedInject constructor( homeserver = accountProvider.title, isMatrix = accountProvider.isMatrixOrg, isAccountCreation = params.isAccountCreation, - changeServerAction = changeServerAction.value, + loginFlow = loginFlowAction.value, eventSink = ::handleEvents ) } - private fun CoroutineScope.submit(homeserverUrl: MutableState, changeServerAction: MutableState>) = launch { + private fun CoroutineScope.submit( + homeserverUrl: MutableState, + loginFlowAction: MutableState>, + ) = launch { suspend { val domain = tryOrNull { URL(homeserverUrl.value) }?.host ?: homeserverUrl.value - authenticationService.setHomeserver(domain).getOrThrow() homeserverUrl.value = domain - }.execute(changeServerAction, errorMapping = ChangeServerError::from) + authenticationService.setHomeserver(domain).map { + authenticationService.getHomeserverDetails().value!! + }.map { + if (it.supportsOidcLogin) { + // Retrieve the details right now + LoginFlow.OidcFlow(authenticationService.getOidcUrl().getOrThrow()) + } else if (it.supportsPasswordLogin) { + LoginFlow.PasswordLogin + } else { + throw IllegalStateException("Unsupported login flow") + } + }.getOrThrow() + }.execute(loginFlowAction, errorMapping = ChangeServerError::from) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderState.kt index d7462895d4..fd6a7914fb 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderState.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderState.kt @@ -17,15 +17,20 @@ package io.element.android.features.login.impl.accountprovider import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.auth.OidcDetails // Do not use default value, so no member get forgotten in the presenters. data class AccountProviderState( val homeserver: String, val isMatrix: Boolean, val isAccountCreation: Boolean, - // TODO Rename - val changeServerAction: Async, + val loginFlow: Async, val eventSink: (AccountProviderEvents) -> Unit ) { - val submitEnabled: Boolean get() = homeserver.isNotEmpty() && (changeServerAction is Async.Uninitialized || changeServerAction is Async.Loading) + val submitEnabled: Boolean get() = homeserver.isNotEmpty() && (loginFlow is Async.Uninitialized || loginFlow is Async.Loading) +} + +sealed interface LoginFlow { + object PasswordLogin : LoginFlow + data class OidcFlow(val oidcDetails: OidcDetails) : LoginFlow } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderStateProvider.kt index 908888f2a1..9d68942dae 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderStateProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderStateProvider.kt @@ -31,6 +31,6 @@ fun aAccountProviderState() = AccountProviderState( homeserver = "matrix.org", isMatrix = true, isAccountCreation = false, - changeServerAction = Async.Uninitialized, + loginFlow = Async.Uninitialized, eventSink = {} ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt index 5989dfa8bd..3548504802 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt @@ -31,18 +31,20 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.features.login.impl.R import io.element.android.features.login.impl.changeserver.ChangeServerError -import io.element.android.features.login.impl.changeserver.ChangeServerEvents import io.element.android.features.login.impl.changeserver.SlidingSyncNotSupportedDialog import io.element.android.libraries.architecture.Async import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage +import io.element.android.libraries.designsystem.components.async.AsyncFailure +import io.element.android.libraries.designsystem.components.async.AsyncLoading import io.element.android.libraries.designsystem.components.button.ButtonWithProgress import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.matrix.api.auth.OidcDetails import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.testTag @@ -50,19 +52,19 @@ import io.element.android.libraries.testtags.testTag fun AccountProviderView( state: AccountProviderState, modifier: Modifier = Modifier, - // TODO Rename - onChangeServerSuccess: () -> Unit = {}, + onOidcDetails: (OidcDetails) -> Unit = {}, + onLoginPasswordNeeded: () -> Unit = {}, onLearnMoreClicked: () -> Unit = {}, onChange: () -> Unit = {}, ) { - val isLoading by remember(state.changeServerAction) { + val isLoading by remember(state.loginFlow) { derivedStateOf { - state.changeServerAction is Async.Loading + state.loginFlow is Async.Loading } } val eventSink = state.eventSink - val invalidHomeserverError = (state.changeServerAction as? Async.Failure)?.error as? ChangeServerError.InlineErrorMessage - val slidingSyncNotSupportedError = (state.changeServerAction as? Async.Failure)?.error as? ChangeServerError.SlidingSyncAlert + val invalidHomeserverError = (state.loginFlow as? Async.Failure)?.error as? ChangeServerError.InlineErrorMessage + val slidingSyncNotSupportedError = (state.loginFlow as? Async.Failure)?.error as? ChangeServerError.SlidingSyncAlert HeaderFooterPage( modifier = modifier, @@ -112,6 +114,24 @@ fun AccountProviderView( } } ) { + when (state.loginFlow) { + is Async.Failure -> { + AsyncFailure( + throwable = state.loginFlow.error, + onRetry = { + state.eventSink.invoke(AccountProviderEvents.Continue) + } + ) + } + is Async.Loading -> AsyncLoading() + is Async.Success -> { + when (val loginFlowState = state.loginFlow.state) { + is LoginFlow.OidcFlow -> onOidcDetails(loginFlowState.oidcDetails) + LoginFlow.PasswordLogin -> onLoginPasswordNeeded() + } + } + Async.Uninitialized -> Unit + } if (slidingSyncNotSupportedError != null) { SlidingSyncNotSupportedDialog(onLearnMoreClicked = { onLearnMoreClicked() @@ -129,9 +149,6 @@ fun AccountProviderView( ) } } - if (state.changeServerAction is Async.Success) { - onChangeServerSuccess() - } } @Preview diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordEvents.kt new file mode 100644 index 0000000000..44b442dd08 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordEvents.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.loginpassword + +sealed interface LoginPasswordEvents { + data class SetLogin(val login: String) : LoginPasswordEvents + data class SetPassword(val password: String) : LoginPasswordEvents + object Submit : LoginPasswordEvents + object ClearError : LoginPasswordEvents +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordNode.kt new file mode 100644 index 0000000000..ea731f379b --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordNode.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.loginpassword + +import androidx.compose.runtime.Composable +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.anvilannotations.ContributesNode +import io.element.android.libraries.di.AppScope + +@ContributesNode(AppScope::class) +class LoginPasswordNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: LoginPasswordPresenter, +) : Node(buildContext, plugins = plugins) { + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + LoginPasswordView( + state = state, + modifier = modifier, + onBackPressed = ::navigateUp + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordPresenter.kt new file mode 100644 index 0000000000..af495eb106 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordPresenter.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.loginpassword + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +class LoginPasswordPresenter @Inject constructor( + private val authenticationService: MatrixAuthenticationService, +) : Presenter { + + @Composable + override fun present(): LoginPasswordState { + val localCoroutineScope = rememberCoroutineScope() + val loginAction: MutableState> = remember { + mutableStateOf(Async.Uninitialized) + } + + val formState = rememberSaveable { + mutableStateOf(LoginFormState.Default) + } + + fun handleEvents(event: LoginPasswordEvents) { + when (event) { + is LoginPasswordEvents.SetLogin -> updateFormState(formState) { + copy(login = event.login) + } + is LoginPasswordEvents.SetPassword -> updateFormState(formState) { + copy(password = event.password) + } + LoginPasswordEvents.Submit -> { + localCoroutineScope.submit(formState.value, loginAction) + } + LoginPasswordEvents.ClearError -> loginAction.value = Async.Uninitialized + } + } + + return LoginPasswordState( + formState = formState.value, + loginAction = loginAction.value, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.submit(formState: LoginFormState, loggedInState: MutableState>) = launch { + loggedInState.value = Async.Loading() + authenticationService.login(formState.login.trim(), formState.password) + .onSuccess { sessionId -> + loggedInState.value = Async.Success(sessionId) + } + .onFailure { failure -> + loggedInState.value = Async.Failure(failure) + } + } + + private fun updateFormState(formState: MutableState, updateLambda: LoginFormState.() -> LoginFormState) { + formState.value = updateLambda(formState.value) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordState.kt new file mode 100644 index 0000000000..eac406a1c4 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordState.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.loginpassword + +import android.os.Parcelable +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.parcelize.Parcelize + +data class LoginPasswordState( + val formState: LoginFormState, + val loginAction: Async, + val eventSink: (LoginPasswordEvents) -> Unit +) { + val submitEnabled: Boolean + get() = loginAction !is Async.Failure && + ((formState.login.isNotEmpty() && formState.password.isNotEmpty())) +} + +@Parcelize +data class LoginFormState( + val login: String, + val password: String +) : Parcelable { + + companion object { + val Default = LoginFormState("", "") + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordStateProvider.kt new file mode 100644 index 0000000000..deb7e86acc --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordStateProvider.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.loginpassword + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async + +open class LoginPasswordStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aLoginPasswordState(), + // Loading + aLoginPasswordState().copy(loginAction = Async.Loading()), + // Error + aLoginPasswordState().copy(loginAction = Async.Failure(Exception("An error occurred"))), + ) +} + +fun aLoginPasswordState() = LoginPasswordState( + formState = LoginFormState.Default, + loginAction = Async.Uninitialized, + eventSink = {} +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordView.kt new file mode 100644 index 0000000000..f8b0345c01 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordView.kt @@ -0,0 +1,305 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.loginpassword + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.AutofillType +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.login.impl.R +import io.element.android.features.login.impl.error.loginError +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.ElementTextStyles +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.button.ButtonWithProgress +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.components.form.textFieldState +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextField +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.designsystem.theme.components.autofill +import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.ui.strings.R as StringR + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun LoginPasswordView( + state: LoginPasswordState, + modifier: Modifier = Modifier, + onBackPressed: () -> Unit, +) { + val isLoading by remember(state.loginAction) { + derivedStateOf { + state.loginAction is Async.Loading + } + } + val focusManager = LocalFocusManager.current + + fun submit() { + // Clear focus to prevent keyboard issues with textfields + focusManager.clearFocus(force = true) + + state.eventSink(LoginPasswordEvents.Submit) + } + + Scaffold( + topBar = { + TopAppBar( + title = {}, + navigationIcon = { BackButton(onClick = onBackPressed) }, + ) + } + ) { padding -> + Box( + modifier = modifier + .fillMaxSize() + .imePadding() + .padding(padding) + .consumeWindowInsets(padding) + ) { + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .verticalScroll(state = scrollState) + .padding(horizontal = 16.dp), + ) { + Spacer(Modifier.height(16.dp)) + // Title + Text( + text = stringResource(id = R.string.screen_login_title), + modifier = Modifier + .fillMaxWidth(), + style = ElementTextStyles.Bold.title1, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(32.dp)) + ServerDetailForm( + state = state, + isLoading = isLoading, + submit = ::submit + ) + } + } + } + + if (state.loginAction is Async.Failure) { + LoginErrorDialog(error = state.loginAction.error, onDismiss = { + state.eventSink(LoginPasswordEvents.ClearError) + }) + } +} + +@Composable +fun ServerDetailForm( + state: LoginPasswordState, + isLoading: Boolean, + submit: () -> Unit, + modifier: Modifier = Modifier, +) { + LoginForm(state = state, isLoading = isLoading, onSubmit = submit, modifier = modifier) + + Spacer(Modifier.height(28.dp)) + + // Submit + ButtonWithProgress( + text = stringResource(R.string.screen_login_submit), + showProgress = isLoading, + onClick = submit, + enabled = state.submitEnabled, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.loginContinue) + ) + Spacer(modifier = Modifier.height(32.dp)) +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun LoginForm( + state: LoginPasswordState, + isLoading: Boolean, + onSubmit: () -> Unit, + modifier: Modifier = Modifier +) { + var loginFieldState by textFieldState(stateValue = state.formState.login) + var passwordFieldState by textFieldState(stateValue = state.formState.password) + + val focusManager = LocalFocusManager.current + val eventSink = state.eventSink + + Column(modifier) { + Text( + text = stringResource(R.string.screen_login_form_header), + modifier = Modifier.padding(start = 16.dp), + style = ElementTextStyles.Regular.formHeader + ) + + Spacer(modifier = Modifier.height(8.dp)) + TextField( + value = loginFieldState, + readOnly = isLoading, + modifier = Modifier + .fillMaxWidth() + .onTabOrEnterKeyFocusNext(focusManager) + .testTag(TestTags.loginEmailUsername) + .autofill(autofillTypes = listOf(AutofillType.Username), onFill = { + loginFieldState = it + eventSink(LoginPasswordEvents.SetLogin(it)) + }), + label = { + Text(text = stringResource(R.string.screen_login_username_hint)) + }, + onValueChange = { + loginFieldState = it + eventSink(LoginPasswordEvents.SetLogin(it)) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next + ), + keyboardActions = KeyboardActions(onNext = { + focusManager.moveFocus(FocusDirection.Down) + }), + singleLine = true, + maxLines = 1, + trailingIcon = if (loginFieldState.isNotEmpty()) { + { + IconButton(onClick = { + loginFieldState = "" + }) { + Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(StringR.string.action_clear)) + } + } + } else null, + ) + + var passwordVisible by remember { mutableStateOf(false) } + if (state.loginAction is Async.Loading) { + // Ensure password is hidden when user submits the form + passwordVisible = false + } + Spacer(Modifier.height(20.dp)) + TextField( + value = passwordFieldState, + readOnly = isLoading, + modifier = Modifier + .fillMaxWidth() + .onTabOrEnterKeyFocusNext(focusManager) + .testTag(TestTags.loginPassword) + .autofill(autofillTypes = listOf(AutofillType.Password), onFill = { + passwordFieldState = it + eventSink(LoginPasswordEvents.SetPassword(it)) + }), + onValueChange = { + passwordFieldState = it + eventSink(LoginPasswordEvents.SetPassword(it)) + }, + label = { + Text(text = stringResource(R.string.screen_login_password_hint)) + }, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + val image = + if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff + val description = + if (passwordVisible) stringResource(StringR.string.a11y_hide_password) else stringResource(StringR.string.a11y_show_password) + + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon(imageVector = image, description) + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { onSubmit() } + ), + singleLine = true, + maxLines = 1, + ) + } +} + +@Composable +internal fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) { + ErrorDialog( + content = stringResource(loginError(error)), + onDismiss = onDismiss + ) +} + +@Preview +@Composable +internal fun LoginRootScreenLightPreview(@PreviewParameter(LoginPasswordStateProvider::class) state: LoginPasswordState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +internal fun LoginRootScreenDarkPreview(@PreviewParameter(LoginPasswordStateProvider::class) state: LoginPasswordState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: LoginPasswordState) { + LoginPasswordView( + state = state, + onBackPressed = {} + ) +}